<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>OwEn</title><description>AI、项目与学习记录</description><link>https://owen571.top/</link><language>zh_CN</language><item><title>Docker 学习路线图：镜像到 Compose 的一条主线</title><link>https://owen571.top/posts/study/docker/00-docker-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/docker/00-docker-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>先把“镜像 -&gt; 容器 -&gt; 数据卷 -&gt; 网络 -&gt; Dockerfile -&gt; Compose”的主线打通，再补细节命令。</description><pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Docker 的命令很多，但理解它其实只需要一条主线：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;镜像 -&amp;gt; 容器 -&amp;gt; 数据卷 -&amp;gt; 网络 -&amp;gt; Dockerfile -&amp;gt; Compose&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这套顺序能解释 80% 的 Docker 使用场景。后续不管是部署、迁移、或者改造容器化流程，都可以沿着这条主线倒推回去。&lt;/p&gt;
&lt;p&gt;目前已经整理了第一篇完整入门笔记，后面会继续补更细的实战与排错。&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 学习路线图：先建图，再进入持久化与中断</title><link>https://owen571.top/posts/study/langgraph/00-langgraph-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/00-langgraph-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>这组笔记从 LangGraph 入门开始，沿着 StateGraph、持久化、durable execution、流式与 interrupts 走主线，再补上 time-travel、memory、subgraphs 与典型 agent 模式。</description><pubDate>Sun, 05 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;LangGraph 的官方材料很强，但也有一个和 LangChain 类似的问题：它更像“能力文档”和“特性索引”，不完全像一条平滑的学习路线。&lt;/p&gt;
&lt;p&gt;如果直接按功能点跳着看，很容易出现这几种感觉：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;刚理解 &lt;code&gt;StateGraph&lt;/code&gt;，后面就已经在谈 &lt;code&gt;interrupt&lt;/code&gt;、&lt;code&gt;checkpoint&lt;/code&gt;、&lt;code&gt;task&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Memory / Persistence / Time-travel / Durable execution&lt;/code&gt; 这几块彼此高度相关，但常常被拆着读。&lt;/li&gt;
&lt;li&gt;一些概念第一次出现时只是“先拿来用”，真正的边界要到后面几节才清楚。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这组文集我按“先搭一个图，再逐步让它变得像真正能上线的工作流”的顺序整理成下面这条主线：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;LangGraph 入门：StateGraph、节点、边、工具与记忆初探&lt;/code&gt;
先把最小可运行图搭出来，搞清楚节点、边、状态和工具调用是怎么衔接的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Persistence：线程、检查点、状态历史与 Store&lt;/code&gt;
理解 LangGraph 为什么能回放、恢复、分叉，以及线程和检查点到底保存了什么。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Durable Execution：为什么副作用最好放进 task&lt;/code&gt;
把“能保存状态”和“能安全恢复执行”区分开，建立 durable execution 的基本直觉。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Streaming：图为什么能流式吐 token、状态和调试事件&lt;/code&gt;
把 &lt;code&gt;stream()/astream()&lt;/code&gt; 的几种模式看清楚，理解 LangGraph 的运行时可观测性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Interrupts：人类介入、审批流与恢复执行&lt;/code&gt;
把中断真正放回工作流里看，理解它为什么是 LangGraph 里最重要的能力之一。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Time-travel：重放与分叉&lt;/code&gt;
用检查点回溯历史，做调试、回放与分叉试验。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Memory：短期与长期记忆&lt;/code&gt;
搞清楚短期 checkpoint 和记忆 Store 的职责边界，以及如何管理上下文膨胀。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Subgraphs：子图复用与持久化策略&lt;/code&gt;
让复杂图变成可组合的模块，同时掌握子图的命名空间与持久化模式。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;从流程到 Agent：建图思路&lt;/code&gt;
先画流程，再拆节点与 state，最后才落到可运行的图。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;典型工作模式：Prompt Chaining / Parallel / Routing / Orchestrator / Evaluator&lt;/code&gt;
把常见结构收成模板，方便以后按需套用或组合。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果是第一次系统学 LangGraph，建议按这里的顺序一路往下读：
先学“怎么建图”，再学“怎么让图能恢复、能暂停、能观测”，最后再补齐 time-travel、memory 与 agent 模式，理解会顺很多。&lt;/p&gt;
</content:encoded></item><item><title>Fine Tuning 学习路线图：从微调基础到多模态实战复盘</title><link>https://owen571.top/posts/study/fine-tuning/00-fine-tuning-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/fine-tuning/00-fine-tuning-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>把「微调模型」这组笔记重排成一条更适合连续学习的路线：先理解微调与量化，再进入数据集、LoRA、LLaMA-Factory 和一次完整的多模态微调复盘。</description><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这组内容来自我在 Obsidian 里连续整理的「微调模型」笔记。原笔记本身覆盖面已经很完整了，但阅读顺序更像“边学边补”，因此会同时出现基础概念、数据集格式、LoRA 原理、LLaMA-Factory 参数和一次实际训练复盘。&lt;/p&gt;
&lt;p&gt;整理进博客时，我把它改成了更适合连续学习的 6 篇：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;微调入门：为什么需要微调、学习范式与参数更新范围&lt;/code&gt;
先建立最小心智：为什么仅靠长上下文或知识库有时不够，微调到底在解决什么问题，以及全参数微调、冻结微调、PEFT 之间的差别。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;量化入门：为什么要量化、量化怎么做、常见方法有哪些&lt;/code&gt;
这一篇把原笔记里混在一起的量化部分单独抽出来，方便把“微调”和“量化”分开理解，再在后面重新合流到 QLoRA。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;微调数据集：Alpaca、ShareGPT、多模态格式与 LLaMA-Factory 接入&lt;/code&gt;
如果说微调的上限由模型决定，那下限很大程度上就由数据决定。这一篇重点是数据格式、切分方式，以及 LLaMA-Factory 的 &lt;code&gt;dataset_info&lt;/code&gt; 怎么配。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;LoRA、QLoRA 与 Qwen2.5-VL：从理论到参数选择&lt;/code&gt;
这一篇先回答“LoRA 为什么可行”，再把 LoRA / QLoRA / Qwen2.5-VL 放在一条线上理解，最后落到几个最常调的参数。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;小样本多模态微调实战：可视化标注、训练参数与第一次训练&lt;/code&gt;
这一篇开始进入真正的实践：数据怎么标、第一轮参数怎么选、Loss 曲线怎么看、为什么模型虽然学到了一点，但还远远不够。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;失败复盘与二次优化：system、描述重写与 agent 配合&lt;/code&gt;
最后一篇不是“完美收官”，而是一次更像真实项目的复盘：先承认第一次微调不理想，再重构数据、改 system 思路、引入 agent，把问题拆清楚。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这条路线的目的不是把 Fine Tuning 讲成一堆分散名词，而是尽量把它还原成一条真实工作流：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先判断为什么要微调&lt;/li&gt;
&lt;li&gt;再理解量化和参数高效微调的约束&lt;/li&gt;
&lt;li&gt;然后进入数据、格式和工具链&lt;/li&gt;
&lt;li&gt;最后落到一次真实的训练与复盘&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是第一次系统学这块，建议按这里的顺序读下去。&lt;/p&gt;
</content:encoded></item><item><title>FastAPI 学习路线图：把教程式切分重新排成一条主线</title><link>https://owen571.top/posts/study/fastapi/00-fastapi-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/00-fastapi-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>FastAPI 官方教程很适合查文档，但连续学习时会显得碎。我把目前的 1-21 份笔记和官方重点章节重新排成一条更适合入门的路径。</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;FastAPI 官方教程本身没有问题，问题更多出在它的组织目标。&lt;/p&gt;
&lt;p&gt;它一方面是教程，另一方面又明显承担了“查询手册”的角色，所以经常会出现几种体验：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先抛出一个高级概念，再在后面单独解释&lt;/li&gt;
&lt;li&gt;一个功能点单独拆成一章，连续阅读时会显得碎&lt;/li&gt;
&lt;li&gt;明明属于同一条请求流的内容，被拆散到不同页面&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;拿它查资料很舒服，拿它从头系统学，就会有一点“章节非常细、跳跃又频繁”的感觉。&lt;/p&gt;
&lt;p&gt;这组文集就是按“构建一个服务时，脑子里会经历的顺序”重新排的。顺序不是跟着目录走，而是跟着一条请求真正流过应用时会经过的层次走。&lt;/p&gt;
&lt;h2&gt;1. 应用入口、&lt;code&gt;fastapi dev&lt;/code&gt;、&lt;code&gt;uvicorn&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;先把最小应用跑起来，理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;app = FastAPI()&lt;/code&gt; 到底是什么&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fastapi dev&lt;/code&gt; 在开发阶段帮了什么&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uvicorn&lt;/code&gt; 为什么是 FastAPI 常见搭档&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pyproject.toml&lt;/code&gt; 里的 &lt;code&gt;entrypoint&lt;/code&gt; 是什么&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. URL 输入：路径参数与查询参数&lt;/h2&gt;
&lt;p&gt;把最常见、也最容易混在一起的两类输入先分清：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;路径参数负责定位资源&lt;/li&gt;
&lt;li&gt;查询参数负责附加过滤和控制条件&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 请求体与 Pydantic 模型&lt;/h2&gt;
&lt;p&gt;当输入不再只是 URL 上的几个值，而是一整个 JSON 结构时，就进入请求体和模型层。&lt;/p&gt;
&lt;p&gt;这一步会把下面几件事连起来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求体建模&lt;/li&gt;
&lt;li&gt;多个 body 参数&lt;/li&gt;
&lt;li&gt;嵌套模型&lt;/li&gt;
&lt;li&gt;示例数据和文档展示&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. 参数来源与校验&lt;/h2&gt;
&lt;p&gt;把 &lt;code&gt;Query / Path / Body / Cookie / Header&lt;/code&gt; 统一成一个心智模型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数从哪里来&lt;/li&gt;
&lt;li&gt;规则放在哪里&lt;/li&gt;
&lt;li&gt;复杂校验怎样接进来&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. 输出层：响应模型、状态码与更新语义&lt;/h2&gt;
&lt;p&gt;前面主要都在看“请求怎么进来”，这一块开始看“响应怎么出去”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;response_model&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;返回值过滤&lt;/li&gt;
&lt;li&gt;常见类型系统&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status_code / tags / description&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jsonable_encoder&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PUT / PATCH&lt;/code&gt; 的更新思路&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. 请求编码切换：表单与文件上传&lt;/h2&gt;
&lt;p&gt;这一层会把“不是 JSON 的请求”补完整：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Form&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;File&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UploadFile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;为什么上传文件一定会牵涉到 &lt;code&gt;multipart/form-data&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;7. 依赖注入、&lt;code&gt;yield&lt;/code&gt;、错误处理与安全起步&lt;/h2&gt;
&lt;p&gt;这一层开始接近“真正可维护的服务”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Depends&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;类依赖、子依赖、全局依赖&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yield&lt;/code&gt; 依赖和资源释放&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HTTPException&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;自定义异常处理器&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OAuth2PasswordBearer&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;8. Bigger Applications：&lt;code&gt;APIRouter&lt;/code&gt;、多文件结构、生命周期&lt;/h2&gt;
&lt;p&gt;当单文件应用开始变大，问题就不再是“写不写得出来”，而是“怎么组织”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;APIRouter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;include_router&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;模块划分&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lifespan&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;9. 中间件、CORS、后台任务&lt;/h2&gt;
&lt;p&gt;这是“路由之外还有什么东西会围绕请求工作”的一层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;中间件的请求/响应包裹关系&lt;/li&gt;
&lt;li&gt;CORS 为什么是浏览器问题，不是 FastAPI 特有问题&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BackgroundTasks&lt;/code&gt; 什么时候合适&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;10. 测试、CLI、手动运行与 Workers&lt;/h2&gt;
&lt;p&gt;最后把“怎么运行”和“怎么验证”补齐：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TestClient&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pytest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fastapi dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fastapi run&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uvicorn main:app&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--reload&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--workers&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这条顺序跟官方目录不一样，但更接近第一次系统学 FastAPI 时真正需要的顺序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先把服务跑起来&lt;/li&gt;
&lt;li&gt;再理解请求从哪里进来&lt;/li&gt;
&lt;li&gt;再理解响应怎么出去&lt;/li&gt;
&lt;li&gt;再补依赖、错误、安全&lt;/li&gt;
&lt;li&gt;最后进入工程化和部署&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>claude-code的源码拆解学习</title><link>https://owen571.top/posts/study/%E9%9A%8F%E5%BF%83%E5%8D%9A%E5%AE%A2/claude-code%E7%9A%84%E6%BA%90%E7%A0%81%E6%8B%86%E8%A7%A3%E5%AD%A6%E4%B9%A0/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E9%9A%8F%E5%BF%83%E5%8D%9A%E5%AE%A2/claude-code%E7%9A%84%E6%BA%90%E7%A0%81%E6%8B%86%E8%A7%A3%E5%AD%A6%E4%B9%A0/</guid><description>2026 年 3 月底，Claude Code 在一次 npm 发布中因打包配置错误，将一个 约 57MB 的 cli.js.map 文件意外公开，包含 1906 个 TypeScript/TSX 核心文件、总计 51.2 万行源码。这些内容涉及 Agent 循环引擎、工具系统、记忆与上下文压缩、安全机制等核心实现，以及部分未发布功能（如 AI 宠物、反蒸馏、多 Agent 协作等）。</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;一、总介绍&lt;/h1&gt;
&lt;p&gt;Claude Code 的意外泄露，给了广大 AI 学习者一个非常好的借鉴蓝本。这里，我们按照项目 &lt;strong&gt;learn-claude-code&lt;/strong&gt;（shareAI Lab, MIT 协议）的顺序，一步一步看怎么从简单到复杂，搭建一个 Claude Code 风格的 Agent。&lt;/p&gt;
&lt;p&gt;这个项目的核心论点是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;智能来自模型（model），但让智能变成现实的是 harness（线束/运行环境）。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;模型能推理能编码，但它只能产出文本——碰不到文件系统、不能跑命令、不能读报错。&lt;strong&gt;harness 负责把模型产出的文本变成真实世界的动作&lt;/strong&gt;，再把结果喂回去。二者配合，才是完整的 AI Agent。&lt;/p&gt;
&lt;p&gt;项目把 Claude Code 拆成 12 个递进 session（s01 到 s12），每个 session 都是一个独立可运行的 Python 脚本，代码量从 ~4KB 增长到 ~36KB。&lt;/p&gt;
&lt;h1&gt;二、s01–s12&lt;/h1&gt;
&lt;h2&gt;1. s01：最小 Agent 循环——&quot;一个循环 + 一个 Bash，就是一个 Agent&quot;&lt;/h2&gt;
&lt;p&gt;s01 是整个项目的起点。它演示了一个事实：&lt;strong&gt;不到 30 行核心代码，就能跑起一个可以操作你文件的 AI Agent。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(1) 依赖与环境&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv(override=True)

if os.getenv(&quot;ANTHROPIC_BASE_URL&quot;):
    os.environ.pop(&quot;ANTHROPIC_AUTH_TOKEN&quot;, None)

client = Anthropic(base_url=os.getenv(&quot;ANTHROPIC_BASE_URL&quot;))
MODEL = os.environ[&quot;MODEL_ID&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只需要 3 个第三方包：&lt;code&gt;anthropic&lt;/code&gt;（调用 Claude API）、&lt;code&gt;python-dotenv&lt;/code&gt;（加载 &lt;code&gt;.env&lt;/code&gt; 里的 API key）、&lt;code&gt;pyyaml&lt;/code&gt;（后续 session 用到）。&lt;/p&gt;
&lt;p&gt;注意第 47 行的 &lt;code&gt;os.environ.pop(&quot;ANTHROPIC_AUTH_TOKEN&quot;, None)&lt;/code&gt;：当设置了 &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt;（使用第三方兼容 API）时，删除从环境继承的 auth token，避免认证冲突。&lt;/p&gt;
&lt;p&gt;这里需要补充一下 claude 请求格式与  oepnai 的不同。先看一下相同点吧，底层通信都是基于 HTTP 的RESTful API；数据交换格式都是 JSON；都抽象了基于 role（角色）和 content（内容）的对话历史数组模式（而不是文本补全）；都支持SSE协议来最大程度降低TTFB；原生支持函数调用（Function Calling / Tool Use）和多模态（视觉）输入。&lt;/p&gt;
&lt;p&gt;关键区别就在一下几个点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;O将system放在对话的第一个位置作为一个特殊角色，而C将system剥离数组，当成了一个顶级参数，与model、message同级。&lt;/li&gt;
&lt;li&gt;C严格遵循user和assistant交替出现的规则，O则相对宽容允许连续出现。&lt;/li&gt;
&lt;li&gt;比较重要的一点，O在有工具调用的时候在 assistant 消息中返回 tool_calls 数组，提交工具执行结果时，需要新增一条角色为 tool 的消息，并通过 tool_call_id 与之前的调用关联；C调用工具时，内容（content）会变成一个数组，其中包含类型为 tool_use 的对象，提交工具结果时，需要新增一条角色为 user（注意是 user，而不是单独的 tool 角色）的消息，其内容为类型为 tool_result 的对象，并附带 tool_use_id。&lt;/li&gt;
&lt;li&gt;鉴权模式，O是Bearer Token，C是自定义的，强制要求声明 API 版本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;来看一下标准带工具调用情况下两者的JSON差距，前面为O后面为C。首先是工具声明，前者嵌套更深严格区分function，后者结构更扁平，参数叫 input_schema：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;tools&quot;: [
  {
    &quot;type&quot;: &quot;function&quot;,
    &quot;function&quot;: {
      &quot;name&quot;: &quot;get_weather&quot;,
      &quot;description&quot;: &quot;获取指定城市的天气&quot;,
      &quot;parameters&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
          &quot;location&quot;: { &quot;type&quot;: &quot;string&quot; }
        }
      }
    }
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&quot;tools&quot;: [
  {
    &quot;name&quot;: &quot;get_weather&quot;,
    &quot;description&quot;: &quot;获取指定城市的天气&quot;,
    &quot;input_schema&quot;: {
      &quot;type&quot;: &quot;object&quot;,
      &quot;properties&quot;: {
        &quot;location&quot;: { &quot;type&quot;: &quot;string&quot; }
      }
    }
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后是模型决定工具，这里有巨大差异，OpenAI 传回的是字符串格式的 JSON，需要你自己 json.loads()；而 Claude 直接传回了解析好的 JSON 对象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;role&quot;: &quot;assistant&quot;,
  &quot;content&quot;: null,
  &quot;tool_calls&quot;: [
    {
      &quot;id&quot;: &quot;call_abc123&quot;,
      &quot;type&quot;: &quot;function&quot;,
      &quot;function&quot;: {
        &quot;name&quot;: &quot;get_weather&quot;,
        &quot;arguments&quot;: &quot;{\&quot;location\&quot;: \&quot;Wuhan\&quot;}&quot; 
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;role&quot;: &quot;assistant&quot;,
  &quot;content&quot;: [
    {
      &quot;type&quot;: &quot;text&quot;,
      &quot;text&quot;: &quot;好的，我来帮你查一下。&quot;
    },
    {
      &quot;type&quot;: &quot;tool_use&quot;,
      &quot;id&quot;: &quot;toolu_xyz789&quot;,
      &quot;name&quot;: &quot;get_weather&quot;,
      &quot;input&quot;: {
        &quot;location&quot;: &quot;Wuhan&quot;
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终将工具执行结果返回模型的时候，前者必须新增一个专属的 role: &quot;tool&quot;，后者则是必须作为 role: &quot;user&quot; 消息发送，并在 content 数组里标记 tool_result（这也是两者要互相转化最麻烦的一点）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;role&quot;: &quot;tool&quot;,
  &quot;tool_call_id&quot;: &quot;call_abc123&quot;,
  &quot;content&quot;: &quot;{\&quot;temperature\&quot;: 25, \&quot;condition\&quot;: \&quot;Sunny\&quot;}&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;role&quot;: &quot;user&quot;,
  &quot;content&quot;: [
    {
      &quot;type&quot;: &quot;tool_result&quot;,
      &quot;tool_use_id&quot;: &quot;toolu_xyz789&quot;,
      &quot;content&quot;: &quot;{\&quot;temperature\&quot;: 25, \&quot;condition\&quot;: \&quot;Sunny\&quot;}&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果要在底层打通两套接口，路由层需要重点处理：OpenAI 的 role: &quot;tool&quot; 必须被强制映射为 Claude 的 role: &quot;user&quot; + type: &quot;tool_result&quot; 结构。&lt;/p&gt;
&lt;h3&gt;(2) 系统提示词——赋予模型&quot;身份&quot;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;SYSTEM = f&quot;You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don&apos;t explain.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行是整个 harness 的入口。&lt;code&gt;os.getcwd()&lt;/code&gt; 被&lt;strong&gt;直接拼进字符串&lt;/strong&gt;——模型收到的不是函数调用，而是当前目录的真实路径（如 &lt;code&gt;/home/ubuntu/owen&lt;/code&gt;）。模型不知道自己在哪台机器上，它只知道 prompt 里写了这个路径，然后基于此&quot;推理&quot;应该执行什么命令。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;权限从哪来？&lt;/strong&gt; 权限来自你运行 &lt;code&gt;python agents/s01_agent_loop.py&lt;/code&gt; 时你自己的 shell。Python 进程继承了你的所有权限——能读写的文件、能执行的命令，和你在终端敲命令是一样的。&lt;/p&gt;
&lt;h3&gt;(3) 工具定义——模型唯一能&quot;调用&quot;的东西&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;TOOLS = [{
    &quot;name&quot;: &quot;bash&quot;,
    &quot;description&quot;: &quot;Run a shell command.&quot;,
    &quot;input_schema&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {&quot;command&quot;: {&quot;type&quot;: &quot;string&quot;}},
        &quot;required&quot;: [&quot;command&quot;],
    },
}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;工具定义不是 Python 函数，只是一段 &lt;strong&gt;JSON Schema 描述&lt;/strong&gt;。发给模型后，模型会输出类似这样的 JSON：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;tool_use&quot;,
  &quot;name&quot;: &quot;bash&quot;,
  &quot;id&quot;: &quot;toolu_01xxx&quot;,
  &quot;input&quot;: {&quot;command&quot;: &quot;ls&quot;}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;模型只负责&quot;说要做什么&quot;。真正执行的是 harness。&lt;/strong&gt; 模型产生意图，harness 赋予能力。&lt;/p&gt;
&lt;h3&gt;(4) 工具执行——&lt;code&gt;run_bash&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def run_bash(command: str) -&amp;gt; str:
    dangerous = [&quot;rm -rf /&quot;, &quot;sudo&quot;, &quot;shutdown&quot;, &quot;reboot&quot;, &quot;&amp;gt; /dev/&quot;]
    if any(d in command for d in dangerous):
        return &quot;Error: Dangerous command blocked&quot;
    try:
        r = subprocess.run(
            command, shell=True, cwd=os.getcwd(),
            capture_output=True, text=True, timeout=120
        )
        out = (r.stdout + r.stderr).strip()
        return out[:50000] if out else &quot;(no output)&quot;
    except subprocess.TimeoutExpired:
        return &quot;Error: Timeout (120s)&quot;
    except (FileNotFoundError, OSError) as e:
        return f&quot;Error: {e}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;subprocess.run(command, shell=True, cwd=os.getcwd())&lt;/code&gt;&lt;/strong&gt; — 模型输出的字符串被直接交给 shell 执行。这就是为什么 AI 可以操作文件：本质上和你自己在终端敲命令一样。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;危险命令拦截&lt;/strong&gt; — 硬编码了 5 条关键词，在命令到达 &lt;code&gt;subprocess&lt;/code&gt; 之前做简单过滤。这非常粗糙（比如 &lt;code&gt;rm -rf ~/*&lt;/code&gt; 就绕过去了），真实的 Claude Code 有完整的权限系统和 hooks 机制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;输出截断&lt;/strong&gt; — &lt;code&gt;out[:50000]&lt;/code&gt; 防止大量输出撑爆 token 预算（后面 s06 会专门处理上下文压缩）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;超时保护&lt;/strong&gt; — &lt;code&gt;timeout=120&lt;/code&gt;，防止命令卡死。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(5) 核心循环——整个 s01 的灵魂&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def agent_loop(messages: list):
    while True:
        # 1. 将消息和工具定义一起发给 LLM
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )

        # 2. 追加 assistant 消息
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})

        # 3. 如果模型没有调用工具，结束循环
        if response.stop_reason != &quot;tool_use&quot;:
            return

        # 4. 执行每个工具调用，收集结果
        results = []
        for block in response.content:
            if block.type == &quot;tool_use&quot;:
                print(f&quot;\033[33m$ {block.input[&apos;command&apos;]}\033[0m&quot;)
                output = run_bash(block.input[&quot;command&quot;])
                print(output[:200])
                results.append({
                    &quot;type&quot;: &quot;tool_result&quot;,
                    &quot;tool_use_id&quot;: block.id,
                    &quot;content&quot;: output,
                })

        # 5. 把工具结果作为 user 消息追加，回到步骤 1
        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;流程图：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+--------+      +-------+      +---------+
|  User  | ---&amp;gt; |  LLM  | ---&amp;gt; |  Tool   |
| prompt |      |       |      | execute |
+--------+      +---+---+      +----+----+
                    ^                |
                    |   tool_result  |
                    +----------------+
                    (loop until stop_reason != &quot;tool_use&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;循环不变式&lt;/strong&gt;：模型只要还在返回 &lt;code&gt;stop_reason == &quot;tool_use&quot;&lt;/code&gt;，就把工具结果塞回 &lt;code&gt;messages&lt;/code&gt; 再问一次；一旦返回 &lt;code&gt;stop_reason == &quot;end_turn&quot;&lt;/code&gt;，循环终止。&lt;/p&gt;
&lt;p&gt;一个设计细节：工具执行结果用 &lt;code&gt;role: &quot;user&quot;&lt;/code&gt; 而不是 &lt;code&gt;role: &quot;tool&quot;&lt;/code&gt; 返回。这是 Anthropic 消息协议的约定——工具结果被追加为 user role 的消息（因为它是&quot;外部输入&quot;，不是模型自己生成的）。&lt;/p&gt;
&lt;h3&gt;(6) 交互循环——REPL 外壳&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    history = []
    while True:
        try:
            query = input(&quot;\033[36ms01 &amp;gt;&amp;gt; \033[0m&quot;)
        except (EOFError, KeyboardInterrupt):
            break
        if query.strip().lower() in (&quot;q&quot;, &quot;exit&quot;, &quot;&quot;):
            break
        history.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query})
        agent_loop(history)
        # 打印最终响应
        response_content = history[-1][&quot;content&quot;]
        if isinstance(response_content, list):
            for block in response_content:
                if hasattr(block, &quot;text&quot;):
                    print(block.text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;外层是一个简单的 &lt;code&gt;while True&lt;/code&gt; REPL（Read-Eval-Print Loop）。&lt;code&gt;history&lt;/code&gt; 在所有轮次中持续增长——上一次问题和模型的回答（含所有工具调用）都在里面，所以模型能&quot;记住&quot;上下文。&lt;/p&gt;
&lt;h3&gt;(7) macOS UTF-8 输入补丁&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;try:
    import readline
    # #143 UTF-8 backspace fix for macOS libedit
    readline.parse_and_bind(&apos;set bind-tty-special-chars off&apos;)
    readline.parse_and_bind(&apos;set input-meta on&apos;)
    readline.parse_and_bind(&apos;set output-meta on&apos;)
    readline.parse_and_bind(&apos;set convert-meta off&apos;)
    readline.parse_and_bind(&apos;set enable-meta-keybindings on&apos;)
except ImportError:
    pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;macOS 默认用 &lt;code&gt;libedit&lt;/code&gt;（而非 GNU readline），处理中文、日文等多字节字符时退格键可能只删半个字符导致乱码。这 6 行配置切换 libedit 的字符处理模式，让 UTF-8 输入正常。&lt;code&gt;#143&lt;/code&gt; 引用对应的 GitHub issue/PR 编号。Linux 上 Python 自带 GNU readline，这段代码无害但不起作用。&lt;/p&gt;
&lt;h3&gt;(8) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd learn-claude-code
python agents/s01_agent_loop.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内置的测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Create a file called hello.py that prints &quot;Hello, World!&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;List all Python files in this directory&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;What is the current git branch?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Create a directory called test_output and write 3 files in it&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s01 暴露了 AI Agent 的本质结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;模型 = 产生意图（&quot;我想执行 ls&quot;）
harness = 赋予能力（Python 调用 subprocess.run）
权限 = 在 harness 层控制（危险命令拦截、用户确认）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型完全不知道自己在哪台机器上，它只是收到了一段带有当前目录路径的 system prompt，然后基于这段文本进行&quot;推理&quot;。&lt;strong&gt;它说的所有话都是文本——是 harness 把文本变成了真实世界的动作。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这就是整个项目的核心论点：智能来自模型，但让智能变成现实的是 harness。后面 11 个章节都在这个循环上叠加机制（任务规划、子 Agent、技能系统、上下文压缩、后台任务、团队协作……），但 &lt;code&gt;while True&lt;/code&gt; 这层循环本身始终不变。&lt;/p&gt;
&lt;h2&gt;2. s02：工具分发——&quot;加工具不改循环&quot;&lt;/h2&gt;
&lt;p&gt;s02 的核心变化就一句话：&lt;strong&gt;工具从 1 个变成 4 个，循环代码一行没动。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+--------+      +-------+      +------------------+
|  User  | ---&amp;gt; |  LLM  | ---&amp;gt; | Tool Dispatch    |
| prompt |      |       |      | {                |
+--------+      +---+---+      |   bash: run_bash |
                    ^           |   read: run_read |
                    |           |   write: run_wr  |
                    +-----------+   edit: run_edit |
                    tool_result | }                |
                                +------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(1) 路径沙箱——&lt;code&gt;safe_path&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;这是 s02 最重要的新增基础设施。s01 的 bash 对文件系统没有边界，&lt;code&gt;cat ~/.ssh/id_rsa&lt;/code&gt; 也能执行。s02 给文件工具加了一道门：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WORKDIR = Path.cwd()

def safe_path(p: str) -&amp;gt; Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f&quot;Path escapes workspace: {p}&quot;)
    return path
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三步检查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;WORKDIR / p&lt;/code&gt; — 把输入路径拼到工作目录下&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.resolve()&lt;/code&gt; — 解析掉所有 &lt;code&gt;..&lt;/code&gt; 和符号链接，得到绝对路径&lt;/li&gt;
&lt;li&gt;&lt;code&gt;is_relative_to(WORKDIR)&lt;/code&gt; — 检查解析后的路径是否还在工作目录内&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;../../etc/passwd&lt;/code&gt; → resolve 后变成 &lt;code&gt;/etc/passwd&lt;/code&gt; → 不在 &lt;code&gt;/home/ubuntu/owen&lt;/code&gt; 下 → 抛异常。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但这个沙箱有一个大漏洞：它只保护了 read_file / write_file / edit_file，不保护 bash。&lt;/strong&gt; bash 工具直接走 &lt;code&gt;subprocess.run(command, shell=True)&lt;/code&gt;，模型说 &lt;code&gt;cat ~/.ssh/id_rsa.pub&lt;/code&gt; 就能读，说 &lt;code&gt;cat /etc/passwd&lt;/code&gt; 也行。实际测试中，模型通过 bash 读到了 &lt;code&gt;~/.ssh/&lt;/code&gt; 下的公钥——&lt;code&gt;safe_path&lt;/code&gt; 在这里完全被绕过了。&lt;/p&gt;
&lt;p&gt;这是故意留的设计张力：bash 给了模型最大灵活性，但也给了最大攻击面。后面 s06（权限系统）和 s12（worktree 隔离）会逐步解决这个问题。这里先记住一个原则：&lt;strong&gt;只要有不受限的 bash，任何文件级沙箱都有后门。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(2) 三个新工具的函数实现&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;read_file&lt;/strong&gt; — 读文件，支持行数限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def run_read(path: str, limit: int = None) -&amp;gt; str:
    text = safe_path(path).read_text()
    lines = text.splitlines()
    if limit and limit &amp;lt; len(lines):
        lines = lines[:limit] + [f&quot;... ({len(lines) - limit} more lines)&quot;]
    return &quot;\n&quot;.join(lines)[:50000]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比 &lt;code&gt;cat&lt;/code&gt; 好在：可控行数、不会截断半个 UTF-8 字符、告知被截掉的行数。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;write_file&lt;/strong&gt; — 写文件，自动创建父目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def run_write(path: str, content: str) -&amp;gt; str:
    fp = safe_path(path)
    fp.parent.mkdir(parents=True, exist_ok=True)
    fp.write_text(content)
    return f&quot;Wrote {len(content)} bytes to {path}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mkdir(parents=True, exist_ok=True)&lt;/code&gt; 省去了先 &lt;code&gt;mkdir -p&lt;/code&gt; 再写的两步操作。返回值直接给 LLM 看写入结果，形成闭环。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;edit_file&lt;/strong&gt; — 精确文本替换（这是 Claude Code 实际使用的方式，而非 sed/awk）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def run_edit(path: str, old_text: str, new_text: str) -&amp;gt; str:
    fp = safe_path(path)
    content = fp.read_text()
    if old_text not in content:
        return f&quot;Error: Text not found in {path}&quot;
    fp.write_text(content.replace(old_text, new_text, 1))
    return f&quot;Edited {path}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意 &lt;code&gt;replace(old_text, new_text, 1)&lt;/code&gt; 中的 &lt;code&gt;1&lt;/code&gt;——&lt;strong&gt;只替换第一次出现&lt;/strong&gt;。因为如果 LLM 传了一个太短的 &lt;code&gt;old_text&lt;/code&gt;（比如单个变量名），全量替换会改掉不该改的地方。真正的 Claude Code 的 Edit 工具也做单次替换，且要求 &lt;code&gt;old_string&lt;/code&gt; 在文件中唯一，否则报错。&lt;/p&gt;
&lt;h3&gt;(3) 分发映射——Dispatch Map&lt;/h3&gt;
&lt;p&gt;这是 s02 的架构亮点。工具名到处理函数的映射不用 &lt;code&gt;if/elif&lt;/code&gt; 链，而用字典：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {
    &quot;bash&quot;:       lambda **kw: run_bash(kw[&quot;command&quot;]),
    &quot;read_file&quot;:  lambda **kw: run_read(kw[&quot;path&quot;], kw.get(&quot;limit&quot;)),
    &quot;write_file&quot;: lambda **kw: run_write(kw[&quot;path&quot;], kw[&quot;content&quot;]),
    &quot;edit_file&quot;:  lambda **kw: run_edit(kw[&quot;path&quot;], kw[&quot;old_text&quot;], kw[&quot;new_text&quot;]),
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个 lambda 做了同一件事：&lt;strong&gt;从模型返回的 kwargs 中提取自己需要的参数，传给具体函数&lt;/strong&gt;。这是一种适配器模式——模型返回的是扁平的 &lt;code&gt;{&quot;path&quot;: &quot;x&quot;, &quot;content&quot;: &quot;y&quot;}&lt;/code&gt;，而每个函数要的参数名和数量不同。lambda 完成了&quot;模型输出 → 函数签名&quot;的映射。&lt;/p&gt;
&lt;p&gt;后续 session 加新工具就是在这个字典里加一行，循环完全不用动。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个容易忽略的点：&lt;code&gt;TOOL_HANDLERS&lt;/code&gt; 和 &lt;code&gt;TOOLS&lt;/code&gt; 是两个不同的东西。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {          # 执行层 — 留在 harness 本地，Python dict
    &quot;bash&quot;:  lambda **kw: run_bash(kw[&quot;command&quot;]),
    ...
}

TOOLS = [{...}, {...}]     # 定义层 — 发给模型，JSON Schema 数组
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TOOLS&lt;/th&gt;
&lt;th&gt;TOOL_HANDLERS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;是什么&lt;/td&gt;
&lt;td&gt;JSON Schema 数组&lt;/td&gt;
&lt;td&gt;Python dict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发到哪里&lt;/td&gt;
&lt;td&gt;发给模型（API 的 &lt;code&gt;tools&lt;/code&gt; 参数）&lt;/td&gt;
&lt;td&gt;留在本地，模型永远看不到&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;作用&lt;/td&gt;
&lt;td&gt;告诉模型&quot;你可以调什么&quot;&lt;/td&gt;
&lt;td&gt;告诉 Python&quot;调了之后执行哪个函数&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内容的性质&lt;/td&gt;
&lt;td&gt;文本描述 + 参数 schema&lt;/td&gt;
&lt;td&gt;lambda / 函数引用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;s01 没有这个分离——只有一个 &lt;code&gt;TOOLS&lt;/code&gt;，执行是硬编码的。s02 引入 dispatch map 时就把二者拆开了，s03 只是照惯例各加了一行。这个分离是 harness 设计的核心模式：&lt;strong&gt;给模型看的和本地执行的是两套东西，用名字做桥接。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(4) 循环中的分发调用&lt;/h3&gt;
&lt;p&gt;对比 s01 和 s02 的循环体变化：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# s01 — 硬编码只调 bash
for block in response.content:
    if block.type == &quot;tool_use&quot;:
        output = run_bash(block.input[&quot;command&quot;])

# s02 — 字典分发，任意工具
for block in response.content:
    if block.type == &quot;tool_use&quot;:
        handler = TOOL_HANDLERS.get(block.name)
        output = handler(**block.input) if handler else f&quot;Unknown tool: {block.name}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;TOOL_HANDLERS.get(block.name)&lt;/code&gt; 一次查找替代了 s01 的硬编码。如果模型幻觉了一个不存在的工具名，返回 &lt;code&gt;&quot;Unknown tool&quot;&lt;/code&gt; 让模型自行纠正。&lt;/p&gt;
&lt;h3&gt;(5) 工具定义——JSON Schema 数组&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;TOOLS = [
    {&quot;name&quot;: &quot;bash&quot;, &quot;description&quot;: &quot;Run a shell command.&quot;,
     &quot;input_schema&quot;: {...}},
    {&quot;name&quot;: &quot;read_file&quot;, &quot;description&quot;: &quot;Read file contents.&quot;,
     &quot;input_schema&quot;: {&quot;type&quot;: &quot;object&quot;,
         &quot;properties&quot;: {&quot;path&quot;: {&quot;type&quot;: &quot;string&quot;}, &quot;limit&quot;: {&quot;type&quot;: &quot;integer&quot;}},
         &quot;required&quot;: [&quot;path&quot;]}},
    {&quot;name&quot;: &quot;write_file&quot;, &quot;description&quot;: &quot;Write content to file.&quot;,
     &quot;input_schema&quot;: {&quot;type&quot;: &quot;object&quot;,
         &quot;properties&quot;: {&quot;path&quot;: {&quot;type&quot;: &quot;string&quot;}, &quot;content&quot;: {&quot;type&quot;: &quot;string&quot;}},
         &quot;required&quot;: [&quot;path&quot;, &quot;content&quot;]}},
    {&quot;name&quot;: &quot;edit_file&quot;, &quot;description&quot;: &quot;Replace exact text in file.&quot;,
     &quot;input_schema&quot;: {&quot;type&quot;: &quot;object&quot;,
         &quot;properties&quot;: {&quot;path&quot;: {&quot;type&quot;: &quot;string&quot;}, &quot;old_text&quot;: {&quot;type&quot;: &quot;string&quot;}, &quot;new_text&quot;: {&quot;type&quot;: &quot;string&quot;}},
         &quot;required&quot;: [&quot;path&quot;, &quot;old_text&quot;, &quot;new_text&quot;]}},
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个工具都是自描述的——模型看 &lt;code&gt;description&lt;/code&gt; 知道什么时候用它，看 &lt;code&gt;input_schema&lt;/code&gt; 知道它需要什么参数。这个数组就是模型和真实世界的&lt;strong&gt;唯一接口&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;(6) s01 → s02 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s01&lt;/th&gt;
&lt;th&gt;s02&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具数量&lt;/td&gt;
&lt;td&gt;1 (bash)&lt;/td&gt;
&lt;td&gt;4 (bash + read/write/edit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具调用方式&lt;/td&gt;
&lt;td&gt;硬编码 &lt;code&gt;run_bash()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TOOL_HANDLERS&lt;/code&gt; 字典分发&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;路径安全&lt;/td&gt;
&lt;td&gt;无（bash 任意路径）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;safe_path()&lt;/code&gt; 沙箱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent loop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;while True&lt;/code&gt; + &lt;code&gt;stop_reason&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;完全相同&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(7) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s02_tool_use.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Read the file requirements.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Create a file called greet.py with a greet(name) function&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Edit greet.py to add a docstring to the function&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Read greet.py to verify the edit worked&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s02 证明了 harness 设计中最重要的一条原则：&lt;strong&gt;工具系统和循环是正交的。&lt;/strong&gt; 循环只负责&quot;调 API → 看 stop_reason → 执行工具 → 塞回结果&quot;，它不关心有多少工具、每个工具做什么。加工具 = 加 handler + 加 schema，别碰循环。&lt;/p&gt;
&lt;p&gt;另外，&lt;code&gt;safe_path&lt;/code&gt; 这种&lt;strong&gt;工具层沙箱&lt;/strong&gt;比 bash 层的字符串过滤可靠得多——在代码层面精确控制边界，而不是靠关键词匹配去猜攻击。后续 session 的安全机制都遵循这个思路：权限控制在 harness 层，不在 prompt 里。&lt;/p&gt;
&lt;h2&gt;3. s03：TodoWrite——&quot;没有计划的 Agent 走哪算哪&quot;&lt;/h2&gt;
&lt;p&gt;s03 解决一个问题：GPT/Claude 做多步任务时，做到一半就忘了自己要干什么。对话越长越严重——前面列的计划被后续工具输出淹没了，模型开始即兴发挥。&lt;/p&gt;
&lt;p&gt;解决方案：&lt;strong&gt;让模型自己写待办清单，harness 负责两件事：(1) 记录状态 (2) 忘了就催。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(1) TodoManager——有状态的待办管理器&lt;/h3&gt;
&lt;p&gt;这是 s03 的核心数据结构。之前的工具函数都是无状态的（读就是读、写就是写），而 &lt;code&gt;TodoManager&lt;/code&gt; 是一个 &lt;strong&gt;Python 对象，在会话期间保持状态&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TodoManager:
    def __init__(self):
        self.items = []           # 内存中的 todo 列表，整个会话存活

    def update(self, items: list) -&amp;gt; str:
        if len(items) &amp;gt; 20:
            raise ValueError(&quot;Max 20 todos allowed&quot;)
        validated = []
        in_progress_count = 0
        for i, item in enumerate(items):
            text = str(item.get(&quot;text&quot;, &quot;&quot;)).strip()
            status = str(item.get(&quot;status&quot;, &quot;pending&quot;)).lower()
            item_id = str(item.get(&quot;id&quot;, str(i + 1)))
            if not text:
                raise ValueError(f&quot;Item {item_id}: text required&quot;)
            if status not in (&quot;pending&quot;, &quot;in_progress&quot;, &quot;completed&quot;):
                raise ValueError(f&quot;Item {item_id}: invalid status &apos;{status}&apos;&quot;)
            if status == &quot;in_progress&quot;:
                in_progress_count += 1
            validated.append({&quot;id&quot;: item_id, &quot;text&quot;: text, &quot;status&quot;: status})
        if in_progress_count &amp;gt; 1:
            raise ValueError(&quot;Only one task can be in_progress at a time&quot;)
        self.items = validated
        return self.render()

    def render(self) -&amp;gt; str:
        if not self.items:
            return &quot;No todos.&quot;
        lines = []
        for item in self.items:
            marker = {&quot;pending&quot;: &quot;[ ]&quot;, &quot;in_progress&quot;: &quot;[&amp;gt;]&quot;, &quot;completed&quot;: &quot;[x]&quot;}[item[&quot;status&quot;]]
            lines.append(f&quot;{marker} #{item[&apos;id&apos;]}: {item[&apos;text&apos;]}&quot;)
        done = sum(1 for t in self.items if t[&quot;status&quot;] == &quot;completed&quot;)
        lines.append(f&quot;\n({done}/{len(self.items)} completed)&quot;)
        return &quot;\n&quot;.join(lines)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;update()&lt;/code&gt; 做了严格的输入校验：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;数量限制&lt;/strong&gt; — 最多 20 条，防止模型滥写&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态白名单&lt;/strong&gt; — 只能是 &lt;code&gt;pending&lt;/code&gt; / &lt;code&gt;in_progress&lt;/code&gt; / &lt;code&gt;completed&lt;/code&gt; 三选一&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;唯一 &lt;code&gt;in_progress&lt;/code&gt;&lt;/strong&gt; — 同时只能有一个任务在做。这条规则很关键——它强制模型保持&lt;strong&gt;顺序聚焦&lt;/strong&gt;，不能同时开三个坑&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;必填 &lt;code&gt;text&lt;/code&gt;&lt;/strong&gt; — 空任务没有意义&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;render()&lt;/code&gt; 把结构化数据转成模型能读懂的文本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ ] #1: Fix authentication bug
[&amp;gt;] #2: Add dark mode toggle        ← 当前正在做
[ ] #3: Write tests
[x] #4: Update README

(1/4 completed)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型通过工具结果看到这段渲染文本，就跟自己写了一张便签一样。&lt;/p&gt;
&lt;h3&gt;(2) todo 工具——模型自己写、自己更新&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {
    &quot;bash&quot;:       lambda **kw: run_bash(kw[&quot;command&quot;]),
    &quot;read_file&quot;:  lambda **kw: run_read(kw[&quot;path&quot;], kw.get(&quot;limit&quot;)),
    &quot;write_file&quot;: lambda **kw: run_write(kw[&quot;path&quot;], kw[&quot;content&quot;]),
    &quot;edit_file&quot;:  lambda **kw: run_edit(kw[&quot;path&quot;], kw[&quot;old_text&quot;], kw[&quot;new_text&quot;]),
    &quot;todo&quot;:       lambda **kw: TODO.update(kw[&quot;items&quot;]),   # ← 新增
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;todo&lt;/code&gt; 工具的定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;name&quot;: &quot;todo&quot;,
 &quot;description&quot;: &quot;Update task list. Track progress on multi-step tasks.&quot;,
 &quot;input_schema&quot;: {
     &quot;type&quot;: &quot;object&quot;,
     &quot;properties&quot;: {&quot;items&quot;: {&quot;type&quot;: &quot;array&quot;, &quot;items&quot;: {&quot;type&quot;: &quot;object&quot;,
         &quot;properties&quot;: {
             &quot;id&quot;: {&quot;type&quot;: &quot;string&quot;},
             &quot;text&quot;: {&quot;type&quot;: &quot;string&quot;},
             &quot;status&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;pending&quot;, &quot;in_progress&quot;, &quot;completed&quot;]}
         }, &quot;required&quot;: [&quot;id&quot;, &quot;text&quot;, &quot;status&quot;]}}},
     &quot;required&quot;: [&quot;items&quot;]}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;值得注意的是：这个工具 &lt;strong&gt;没有读能力&lt;/strong&gt;。模型不能查询&quot;我现在的 todos 是什么&quot;，它只能写。那模型怎么知道当前状态？看上次 &lt;code&gt;todo&lt;/code&gt; 工具返回的 render 结果。这引出了一个设计取舍——这里的 todo 状态在 harness（Python 内存），模型只能通过工具返回值&quot;看到&quot;它。真正的 Claude Code 会把状态持久化到文件（s07 会做）。&lt;/p&gt;
&lt;h3&gt;(3) Nag Reminder——harness 的催促机制&lt;/h3&gt;
&lt;p&gt;模型有时会忘了更新 todo。s03 的解法很粗暴也很有效：数轮次，到阈值就催。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def agent_loop(messages: list):
    rounds_since_todo = 0         # ← 新增计数器
    while True:
        response = client.messages.create(...)
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})
        if response.stop_reason != &quot;tool_use&quot;:
            return
        results = []
        used_todo = False
        for block in response.content:
            if block.type == &quot;tool_use&quot;:
                handler = TOOL_HANDLERS.get(block.name)
                output = handler(**block.input) if handler else f&quot;Unknown tool: {block.name}&quot;
                results.append({&quot;type&quot;: &quot;tool_result&quot;, &quot;tool_use_id&quot;: block.id, &quot;content&quot;: str(output)})
                if block.name == &quot;todo&quot;:
                    used_todo = True

        # 计数器更新
        rounds_since_todo = 0 if used_todo else rounds_since_todo + 1

        # 连续 3 轮没调 todo → 注入提醒
        if rounds_since_todo &amp;gt;= 3:
            results.append({
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: &quot;&amp;lt;reminder&amp;gt;Update your todos.&amp;lt;/reminder&amp;gt;&quot;
            })

        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逻辑很清晰：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;本轮调了 todo → rounds_since_todo = 0
本轮没调    → rounds_since_todo += 1
≥ 3         → 在 tool_results 中追加一条文本提醒
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;reminder&amp;gt;&lt;/code&gt; 被作为 &lt;code&gt;type: &quot;text&quot;&lt;/code&gt; 注入到 &lt;code&gt;user&lt;/code&gt; 消息的 &lt;code&gt;content&lt;/code&gt; 数组里。模型看到这条消息，就相当于 harness 拍了拍它的肩膀说&quot;你该更新计划了&quot;。&lt;/p&gt;
&lt;p&gt;这和真实的 Claude Code 一致——你有时会看到系统注入的 &lt;code&gt;&amp;lt;system-reminder&amp;gt;&lt;/code&gt; 标签，做的就是同样的事情。&lt;/p&gt;
&lt;h3&gt;(4) 为什么这个设计有效&lt;/h3&gt;
&lt;p&gt;核心在于三点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;状态在 harness，不在 prompt&lt;/strong&gt; — 如果让模型&quot;在脑子里记&quot;，对话一长就忘。而 todo 列表是 Python 对象，render 结果每次作为工具返回值重新注入，模型相当于不停地看便签。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;唯一 in_progress&lt;/strong&gt; — 物理上不可并行，模型一次只干一件事。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nag 是压力，不是命令&lt;/strong&gt; — harness 不规定模型做哪一步、怎么做，它只提醒&quot;你该更新计划了&quot;。&lt;strong&gt;规划权还在模型手里。&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这也是 harness 哲学的体现：&lt;strong&gt;harness 提供结构（todo 状态机），模型填充内容（具体做什么）。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(5) s02 → s03 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s02&lt;/th&gt;
&lt;th&gt;s03&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具数量&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5 (+todo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;规划&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;TodoManager 有状态管理&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;loop 变化&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;+ rounds_since_todo 计数器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;模型催促&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;3 轮后注入 &lt;code&gt;&amp;lt;reminder&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;约束&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;最多 20 条、唯一 in_progress&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(6) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s03_todo_write.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt（故意给多步任务，观察它是否先列 todo 再动手）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Refactor the file hello.py: add type hints, docstrings, and a main guard&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Create a Python package with __init__.py, utils.py, and tests/test_utils.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Review all Python files and fix any style issues&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s03 引入了一种新的 harness 能力：&lt;strong&gt;不替模型做决定，但给模型提供&quot;别忘事&quot;的结构。&lt;/strong&gt; TodoManager 是一个最简单的状态机——只有 3 个状态、1 条约束（唯一 in_progress）——却大幅提升了多步任务的完成率。&lt;/p&gt;
&lt;p&gt;这也回答了一个常见问题：要不要给 agent 写详细的 prompt 步骤？s03 的答案是 &lt;strong&gt;不要——给结构就够了&lt;/strong&gt;。别在 prompt 里写&quot;第一步做 X、第二步做 Y&quot;，那是固定脚本。给一个 todo 工具 + nag 机制，让模型自己生成和更新计划，灵活得多。&lt;/p&gt;
&lt;h2&gt;4. s04：Subagent——&quot;上下文隔离就是思维隔离&quot;&lt;/h2&gt;
&lt;p&gt;s04 解决的是 LLM Agent 的核心瓶颈：&lt;strong&gt;上下文膨胀&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;Agent 工作越久，messages 数组越臃肿。主对话里已经积压了 50 轮工具调用和结果，然后模型被问到&quot;这个项目用了什么测试框架？&quot;——它读了 5 个文件才找到答案，而这 5 个文件的内容永久污染了主上下文。其实你只需要一个词：&lt;code&gt;pytest&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;解决方案：&lt;strong&gt;派&quot;子 Agent&quot;去查，只带回一句话摘要。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Parent agent                     Subagent
+------------------+             +------------------+
| messages=[...]   |             | messages=[]      |  ← 空白上下文
|                  |  dispatch   |                  |
| tool: task       | ──────────→ | while tool_use:  |
|   prompt=&quot;...&quot;   |             |   read files     |
|                  |  summary    |   search, grep   |
|   result=&quot;pytest&quot;| ←────────── | return last text |
+------------------+             +------------------+
              |
Parent context stays clean.
Subagent context is discarded.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(1) 两套工具、两套身份&lt;/h3&gt;
&lt;p&gt;s04 首次出现了工具的分级——父和子看到的工具不同：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 子 Agent 只有基础工具（没有 task，防止无限递归）
CHILD_TOOLS = [
    {&quot;name&quot;: &quot;bash&quot;, ...},
    {&quot;name&quot;: &quot;read_file&quot;, ...},
    {&quot;name&quot;: &quot;write_file&quot;, ...},
    {&quot;name&quot;: &quot;edit_file&quot;, ...},
]

# 父 Agent = 基础工具 + task 派遣工具
PARENT_TOOLS = CHILD_TOOLS + [
    {&quot;name&quot;: &quot;task&quot;,
     &quot;description&quot;: &quot;Spawn a subagent with fresh context. It shares the filesystem but not conversation history.&quot;,
     &quot;input_schema&quot;: {
         &quot;type&quot;: &quot;object&quot;,
         &quot;properties&quot;: {
             &quot;prompt&quot;: {&quot;type&quot;: &quot;string&quot;},
             &quot;description&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;Short description of the task&quot;}
         },
         &quot;required&quot;: [&quot;prompt&quot;]
     }},
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;系统提示词也分开了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SYSTEM = f&quot;You are a coding agent at {WORKDIR}. Use the task tool to delegate exploration or subtasks.&quot;

SUBAGENT_SYSTEM = f&quot;You are a coding subagent at {WORKDIR}. Complete the given task, then summarize your findings.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;父是&quot;主管&quot;——会派活；子是&quot;执行者&quot;——只干事，汇报。&lt;/p&gt;
&lt;h3&gt;(2) &lt;code&gt;run_subagent&lt;/code&gt;——独立循环 + 上下文丢弃&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def run_subagent(prompt: str) -&amp;gt; str:
    sub_messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}]  # 空白上下文！
    for _ in range(30):  # 安全限制：最多 30 轮
        response = client.messages.create(
            model=MODEL, system=SUBAGENT_SYSTEM, messages=sub_messages,
            tools=CHILD_TOOLS, max_tokens=8000,
        )
        sub_messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})
        if response.stop_reason != &quot;tool_use&quot;:
            break
        results = []
        for block in response.content:
            if block.type == &quot;tool_use&quot;:
                handler = TOOL_HANDLERS.get(block.name)
                output = handler(**block.input) if handler else f&quot;Unknown tool: {block.name}&quot;
                results.append({
                    &quot;type&quot;: &quot;tool_result&quot;, &quot;tool_use_id&quot;: block.id,
                    &quot;content&quot;: str(output)[:50000]
                })
        sub_messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})

    # 只返回最后一段文字摘要——整个 sub_messages 被丢弃
    return &quot;&quot;.join(b.text for b in response.content if hasattr(b, &quot;text&quot;)) or &quot;(no summary)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键设计点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;sub_messages&lt;/code&gt; 从空开始&lt;/strong&gt; — 子 Agent 看不到父对话历史，就像一个新开的 session。它不是 fork，是 fresh。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;for _ in range(30)&lt;/code&gt;&lt;/strong&gt; — 安全兜底，防止子 Agent 陷入死循环。最多 30 个 API 轮次，必须产出结论。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;函数返回时 &lt;code&gt;sub_messages&lt;/code&gt; 直接丢掉&lt;/strong&gt; — 这是 Python 的 GC 行为：函数退出，局部变量销毁。子 Agent 可能读了 10 个文件、跑了 20 个 bash 命令，但这些上下文&lt;strong&gt;不会回到父级&lt;/strong&gt;。父收到的就是一段摘要文本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;共享文件系统，不共享聊天记录&lt;/strong&gt; — 子 Agent 对工作目录的修改会持久化（因为文件系统是共享的），但对话上下文完全隔离。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) 父 loop 中的 task 调度&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;for block in response.content:
    if block.type == &quot;tool_use&quot;:
        if block.name == &quot;task&quot;:                          # ← task 工具：同步阻塞
            desc = block.input.get(&quot;description&quot;, &quot;subtask&quot;)
            prompt = block.input.get(&quot;prompt&quot;, &quot;&quot;)
            print(f&quot;&amp;gt; task ({desc}): {prompt[:80]}&quot;)
            output = run_subagent(prompt)                  # 阻塞等待子 Agent 完成
        else:
            handler = TOOL_HANDLERS.get(block.name)        # 其他工具走正常分发
            output = handler(**block.input) if handler else f&quot;Unknown tool: {block.name}&quot;
        results.append({&quot;type&quot;: &quot;tool_result&quot;, &quot;tool_use_id&quot;: block.id, &quot;content&quot;: str(output)})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意这里是&lt;strong&gt;同步执行&lt;/strong&gt;——父 Agent 派发 task 后会阻塞等待子 Agent 完成。它不是启动一个后台线程，也没有并发。&lt;code&gt;run_subagent(prompt)&lt;/code&gt; 返回之前，父 loop 停在那。s08 会把这种模式升级为后台任务。&lt;/p&gt;
&lt;h3&gt;(4) 为什么不允许递归生成&lt;/h3&gt;
&lt;p&gt;子 Agent 的 &lt;code&gt;CHILD_TOOLS&lt;/code&gt; 里没有 &lt;code&gt;task&lt;/code&gt; 工具。这是刻意的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;父 → task → 子 → task → 孙子 → task → ... 爆炸
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有 &lt;code&gt;task&lt;/code&gt; 工具，子 Agent 就不知道&quot;派子 Agent&quot;这件事存在。它的 system prompt 只让它&quot;完成给定的任务然后总结&quot;，它的 JSON Schema 里没有 task。这就是&lt;strong&gt;工具层面的权限分级&lt;/strong&gt;——不是你告诉子 Agent &quot;别递归&quot;，而是它物理上就没有这个能力。&lt;/p&gt;
&lt;h3&gt;(5) 这个模式的应用场景&lt;/h3&gt;
&lt;p&gt;s04 的模式最适合两类子任务：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;探索/搜索类&lt;/strong&gt; — &quot;找一下这个项目的测试框架是什么&quot;、&quot;列出所有用了 &lt;code&gt;requests&lt;/code&gt; 库的文件&quot;、&quot;检查 auth 模块是怎么处理 token 的&quot;。这类任务需要读很多文件但只需要一个简短结论。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生成/创建类&lt;/strong&gt; — &quot;创建一个 &lt;code&gt;utils.py&lt;/code&gt;，包含 &lt;code&gt;safe_filename()&lt;/code&gt; 和 &lt;code&gt;hash_cache_key()&lt;/code&gt; 两个函数&quot;、&quot;写一个数据库迁移脚本&quot;。子 Agent 写文件，父 Agent 看到结果。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不适合的场景：需要和用户持续交互的任务（子 Agent 没有 input，看不到外部对话）。&lt;/p&gt;
&lt;h3&gt;(6) s03 → s04 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s03&lt;/th&gt;
&lt;th&gt;s04&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具分级&lt;/td&gt;
&lt;td&gt;无（所有工具平等）&lt;/td&gt;
&lt;td&gt;Parent 有 task，Child 没有&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上下文模型&lt;/td&gt;
&lt;td&gt;共享一个 &lt;code&gt;messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;父子隔离，子上下文即用即弃&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System prompt&lt;/td&gt;
&lt;td&gt;1 个&lt;/td&gt;
&lt;td&gt;2 个（父 + 子）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安全边界&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;子 Agent 有 30 轮限制，无 task 防递归&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;返回值&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;仅最后一段文本摘要&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(7) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s04_subagent.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Use a subtask to find what testing framework this project uses&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Delegate: read all .py files and summarize what each one does&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Use a task to create a new module, then verify it from here&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;启动后观察一个细节：父对话历史始终很短，而子 Agent 的内部循环你看不到（没有 print 它的每次工具调用）。你只收到子 Agent 的最终总结。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s04 的核心思想不是&quot;多一个 Agent 干活更快&quot;，而是&lt;strong&gt;上下文隔离 = 思维清晰&lt;/strong&gt;。父对话历史保持干净，杂活交给子 Agent 在它自己的空间里做完，只带回答案。&lt;/p&gt;
&lt;p&gt;这里有一个有趣的类比：&lt;strong&gt;函数调用。&lt;/strong&gt; 在编程里，你不会把一个大函数的内部变量全暴露给调用者——你只返回一个值。s04 做的是同样的事：子 Agent = 函数，prompt = 参数，summary = 返回值，&lt;code&gt;sub_messages&lt;/code&gt; = 函数内部的局部变量，退出即释放。&lt;/p&gt;
&lt;p&gt;另一点值得注意：工具权限的分级从 s04 就开始了。不是给子 Agent 加&quot;规则&quot;让它别调 task——而是它根本&lt;strong&gt;没有 task 的 schema&lt;/strong&gt;。这就是 harness 权限的本质：&lt;strong&gt;控制能力，不控制意图。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;5. s05：Skill 加载——&quot;用到什么知识，临时加载什么&quot;&lt;/h2&gt;
&lt;p&gt;s05 解决的是 system prompt 膨胀问题。&lt;/p&gt;
&lt;p&gt;你有 10 套领域知识想让 Agent 遵循——git 工作流规范、代码审查清单、测试最佳实践、PDF 处理流程……如果全塞进 system prompt，每次 API 调用都带着，10 个 skill × 2000 token = 20000 token 白白烧掉，而当前任务可能一个都用不上。&lt;/p&gt;
&lt;p&gt;解决方案：&lt;strong&gt;两层按需加载——第一层放便宜的名字列表，第二层只在模型请求时才取出完整内容。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(1) Skill 的文件格式——YAML frontmatter + Markdown 正文&lt;/h3&gt;
&lt;p&gt;每个 skill 是 &lt;code&gt;skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt; 目录结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;skills/
  agent-builder/
    SKILL.md          # YAML 头部 + Markdown 指导内容
  code-review/
    SKILL.md
  mcp-builder/
    SKILL.md
  pdf/
    SKILL.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SKILL.md 用前端常见的 frontmatter 格式分隔元数据和正文：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: pdf
description: Process PDF files - extract text, merge, split, and convert
tags: [document]
---

# PDF Processing

## Reading PDFs
Use `pdftotext` (from poppler-utils) to extract text...

## Creating PDFs
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前面的 YAML 块是元数据（便宜，塞进 system prompt），后面是操作指南（贵，仅在加载时取出）。&lt;/p&gt;
&lt;h3&gt;(2) SkillLoader——扫描、解析、两层供给&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class SkillLoader:
    def __init__(self, skills_dir: Path):
        self.skills_dir = skills_dir
        self.skills = {}
        self._load_all()

    def _load_all(self):
        if not self.skills_dir.exists():
            return
        for f in sorted(self.skills_dir.rglob(&quot;SKILL.md&quot;)):
            text = f.read_text()
            meta, body = self._parse_frontmatter(text)
            name = meta.get(&quot;name&quot;, f.parent.name)
            self.skills[name] = {&quot;meta&quot;: meta, &quot;body&quot;: body, &quot;path&quot;: str(f)}

    def _parse_frontmatter(self, text: str) -&amp;gt; tuple:
        &quot;&quot;&quot;用正则解析 --- YAML --- Markdown 结构&quot;&quot;&quot;
        match = re.match(r&quot;^---\n(.*?)\n---\n(.*)&quot;, text, re.DOTALL)
        if not match:
            return {}, text
        try:
            meta = yaml.safe_load(match.group(1)) or {}
        except yaml.YAMLError:
            meta = {}
        return meta, match.group(2).strip()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;rglob(&quot;SKILL.md&quot;)&lt;/code&gt; 递归扫描，你只需创建目录和文件，SkillLoader 自动发现。&lt;code&gt;_parse_frontmatter&lt;/code&gt; 用正则 &lt;code&gt;^---\n(.*?)\n---\n(.*)&lt;/code&gt; 拆出 YAML 头和后边的 Markdown。&lt;/p&gt;
&lt;p&gt;两层供给方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def get_descriptions(self) -&amp;gt; str:
    &quot;&quot;&quot;Layer 1: 轻量描述列表 → 拼进 system prompt&quot;&quot;&quot;
    lines = []
    for name, skill in self.skills.items():
        desc = skill[&quot;meta&quot;].get(&quot;description&quot;, &quot;No description&quot;)
        tags = skill[&quot;meta&quot;].get(&quot;tags&quot;, &quot;&quot;)
        line = f&quot;  - {name}: {desc}&quot;
        if tags:
            line += f&quot; [{tags}]&quot;
        lines.append(line)
    return &quot;\n&quot;.join(lines)

def get_content(self, name: str) -&amp;gt; str:
    &quot;&quot;&quot;Layer 2: 完整内容 → 作为 tool_result 返回&quot;&quot;&quot;
    skill = self.skills.get(name)
    if not skill:
        return f&quot;Error: Unknown skill &apos;{name}&apos;. Available: {&apos;, &apos;.join(self.skills.keys())}&quot;
    return f&quot;&amp;lt;skill name=\&quot;{name}\&quot;&amp;gt;\n{skill[&apos;body&apos;]}\n&amp;lt;/skill&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) system prompt 中只放名字&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;SYSTEM = f&quot;&quot;&quot;You are a coding agent at {WORKDIR}.
Use load_skill to access specialized knowledge before tackling unfamiliar topics.

Skills available:
{SKILL_LOADER.get_descriptions()}&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终生成的 system prompt 大概长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;You are a coding agent at /home/ubuntu/owen.
Use load_skill to access specialized knowledge before tackling unfamiliar topics.

Skills available:
  - agent-builder: Build custom AI agents using best practices [agent]
  - code-review: Review code for quality, security, and performance [code]
  - mcp-builder: Build MCP servers that integrate with Claude [mcp]
  - pdf: Process PDF files - extract text, merge, split, and convert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个 skill 只占 ~100 token（名字 + 一句话描述），而不是完整 2000 token 的操作指南。&lt;/p&gt;
&lt;h3&gt;(4) &lt;code&gt;load_skill&lt;/code&gt; 工具——模型需要时自己调&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {
    &quot;bash&quot;:       lambda **kw: run_bash(kw[&quot;command&quot;]),
    &quot;read_file&quot;:  lambda **kw: run_read(kw[&quot;path&quot;], kw.get(&quot;limit&quot;)),
    &quot;write_file&quot;: lambda **kw: run_write(kw[&quot;path&quot;], kw[&quot;content&quot;]),
    &quot;edit_file&quot;:  lambda **kw: run_edit(kw[&quot;path&quot;], kw[&quot;old_text&quot;], kw[&quot;new_text&quot;]),
    &quot;load_skill&quot;: lambda **kw: SKILL_LOADER.get_content(kw[&quot;name&quot;]),  # ← 新增
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;{&quot;name&quot;: &quot;load_skill&quot;,
 &quot;description&quot;: &quot;Load specialized knowledge by name.&quot;,
 &quot;input_schema&quot;: {
     &quot;type&quot;: &quot;object&quot;,
     &quot;properties&quot;: {&quot;name&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;Skill name to load&quot;}},
     &quot;required&quot;: [&quot;name&quot;]}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型收到任务后，如果觉得需要某个领域的知识，会先调 &lt;code&gt;load_skill(&quot;code-review&quot;)&lt;/code&gt;，harness 把完整的代码审查指南作为 tool_result 注入当前轮次。然后模型基于刚加载的操作指南工作。&lt;/p&gt;
&lt;h3&gt;(5) 为什么走 tool_result 而不是 system prompt？&lt;/h3&gt;
&lt;p&gt;这是 s05 最重要的设计选择。&lt;/p&gt;
&lt;p&gt;如果走 system prompt：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;模型需要 skill → 修改 system → 重新发请求，翻倍 API 调用
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果走 tool_result：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;模型调 load_skill → skill 内容作为 tool_result 进入 messages → 下一轮模型已看到
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;走 tool_result 的好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不打断循环&lt;/strong&gt; — 就是一次普通工具调用，和其他工具行为一致&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只在需要时出现&lt;/strong&gt; — &lt;code&gt;pdf&lt;/code&gt; skill 的 2000 行内容不会出现在一个纯代码任务里&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;和对话上下文一起在 messages 里&lt;/strong&gt; — 模型能自然引用，不会像 system prompt 那样离对话历史太远&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;和其他工具结果一样被压缩/截断&lt;/strong&gt; — 后续 s06 的上下文压缩对 skill 内容一视同仁&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(6) 和 prompt engineering 的区别&lt;/h3&gt;
&lt;p&gt;这个模式不是&quot;写更好的 prompt&quot;，而是&lt;strong&gt;把知识变成可被 Agent 自己调用的资源&lt;/strong&gt;。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;传统 prompt engineering&lt;/th&gt;
&lt;th&gt;s05 skill loading&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;知识位置&lt;/td&gt;
&lt;td&gt;system prompt 或 user prompt&lt;/td&gt;
&lt;td&gt;文件系统中独立的 SKILL.md&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;触发方式&lt;/td&gt;
&lt;td&gt;每次对话都带着&lt;/td&gt;
&lt;td&gt;模型主动调用 load_skill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;token 成本&lt;/td&gt;
&lt;td&gt;全量，每轮都付&lt;/td&gt;
&lt;td&gt;按需，只付一次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可维护性&lt;/td&gt;
&lt;td&gt;改 prompt 模板&lt;/td&gt;
&lt;td&gt;改文件，无需重写代码&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;skill 文件是&lt;strong&gt;数据不是代码&lt;/strong&gt;——新增一个 skill 就是 &lt;code&gt;mkdir + touch SKILL.md + 写 YAML&lt;/code&gt;，不用改 Python。&lt;/p&gt;
&lt;h3&gt;(6.5) 一个常见误解：pdf skill 能&quot;处理 PDF&quot;吗？&lt;/h3&gt;
&lt;p&gt;初学者看到项目里有 &lt;code&gt;skills/pdf/SKILL.md&lt;/code&gt;，直觉反应是&quot;PDF 处理非常复杂（解析字体、渲染引擎、字符编码……），一个 skill 文件怎么可能搞定？&quot;&lt;/p&gt;
&lt;p&gt;实际上，看看 &lt;code&gt;skills/pdf/SKILL.md&lt;/code&gt; 里写了什么：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## Reading PDFs
# 推荐用 pdftotext 或 pymupdf
pdftotext input.pdf -
# 或者
python3 -c &quot;import fitz; doc = fitz.open(&apos;input.pdf&apos;); ...&quot;

## Creating PDFs
# 推荐用 pandoc (从 Markdown 生成)
pandoc input.md -o output.pdf
# 或者用 reportlab 编程生成

## Key Libraries
| Task | Library | Install |
|------|---------|---------|
| Read/Write/Merge | PyMuPDF | pip install pymupdf |
| Create from scratch | ReportLab | pip install reportlab |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;skill 不是 PDF 处理引擎，它是一份操作指南/小抄。&lt;/strong&gt; 里面写了三样东西：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;bash 命令&lt;/strong&gt; — &lt;code&gt;pdftotext&lt;/code&gt;、&lt;code&gt;pandoc&lt;/code&gt;、&lt;code&gt;wkhtmltopdf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python 代码片段&lt;/strong&gt; — 标准库/三方库的调用模板&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;推荐库对照表&lt;/strong&gt; — 什么场景用什么库、怎么安装&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;模型收到这个 skill 后，和之前做的事情完全一样：&lt;strong&gt;调 bash 工具去执行这些命令。&lt;/strong&gt; 如果 &lt;code&gt;pdftotext&lt;/code&gt; 没装，模型会先 &lt;code&gt;pip install pymupdf&lt;/code&gt; 再试 Python 方案。如果 &lt;code&gt;pandoc&lt;/code&gt; 没装，模型会切到 &lt;code&gt;reportlab&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以 pdf skill 的本质是&lt;strong&gt;领域知识注入&lt;/strong&gt;——不是给 Agent 新能力，而是告诉它&quot;处理 PDF 用这些工具就够了，别绕远路&quot;。模型本身已经会写代码、会调 bash、会读报错后修正，skill 只是把 PDF 场景的最佳路径预先告诉它。&lt;/p&gt;
&lt;p&gt;可以这样理解：&lt;strong&gt;skill 相当于一个资深同事给你留的便利贴&lt;/strong&gt;，上面写着&quot;用 pymupdf 别用 pdfplumber，后者太慢&quot;。便利贴没有给你新能力，但它让你做决策更快更准。&lt;/p&gt;
&lt;p&gt;这个机制的好处是：新增领域支持的成本极低。你不需要写&quot;PDF 解析器&quot;、&quot;PDF 渲染器&quot;——你把 Python 生态里已有的工具（pymupdf、pdftotext、pandoc）组织成一份指南，模型自己会按指南去调用它们。&lt;strong&gt;模型的通用能力 + skill 的领域路径 = 领域专家行为。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(7) s04 → s05 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s04&lt;/th&gt;
&lt;th&gt;s05&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具&lt;/td&gt;
&lt;td&gt;5 (基础 + task)&lt;/td&gt;
&lt;td&gt;5 (基础 + load_skill)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;系统提示&lt;/td&gt;
&lt;td&gt;静态&lt;/td&gt;
&lt;td&gt;动态拼接 skill 列表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;知识管理&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;SkillLoader + SKILL.md 文件系统&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;注入策略&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;两层：名字在 system，内容在 tool_result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;循环变化&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;无（又是 dispatch map 加一行）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(8) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s05_skill_loading.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;What skills are available?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Load the agent-builder skill and follow its instructions&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;I need to do a code review -- load the relevant skill first&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s05 的 skill 机制和 s03 的 TodoWrite 在哲学上是一致的：&lt;strong&gt;不要把所有东西塞进 prompt，让 harness 提供按需的结构。&lt;/strong&gt; s03 是按需给规划能力，s05 是按需给领域知识。&lt;/p&gt;
&lt;p&gt;这个两层注入模式——便宜的名字在 system prompt，昂贵的内容在 tool_result——做到了&quot;模型知道什么知识存在，但只在用到时才付 token 代价&quot;。这就是 Claude Code 里你看到的 &lt;code&gt;/pdf&lt;/code&gt; &lt;code&gt;/review&lt;/code&gt; 等 slash command 以及内置 skill 的核心机制。&lt;/p&gt;
&lt;h2&gt;6. s06：上下文压缩——&quot;Agent 可以策略性地遗忘&quot;&lt;/h2&gt;
&lt;p&gt;s06 解决的是 LLM Agent 的终极瓶颈：&lt;strong&gt;上下文窗口有天花板。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;读一个 1000 行的文件 ~4000 token。读 30 个文件、跑 20 条 bash 命令，10 万 token 打不住。不压缩，Agent 根本没法在大项目里工作——messages 数组不断胀大，最终超过 API 的上下文限制，直接报错。&lt;/p&gt;
&lt;p&gt;s06 用三层压缩金字塔解决了这个问题。&lt;/p&gt;
&lt;h3&gt;Layer 1：micro_compact——沉默的清扫工&lt;/h3&gt;
&lt;p&gt;每次 API 调用前自动运行，安静无感。策略很简单——&lt;strong&gt;旧工具结果替换为占位符&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;KEEP_RECENT = 3               # 保留最近 3 个工具结果
PRESERVE_RESULT_TOOLS = {&quot;read_file&quot;}  # read_file 结果永不压缩

def micro_compact(messages: list) -&amp;gt; list:
    # 收集所有 tool_result 的位置
    tool_results = []
    for msg_idx, msg in enumerate(messages):
        if msg[&quot;role&quot;] == &quot;user&quot; and isinstance(msg.get(&quot;content&quot;), list):
            for part_idx, part in enumerate(msg[&quot;content&quot;]):
                if isinstance(part, dict) and part.get(&quot;type&quot;) == &quot;tool_result&quot;:
                    tool_results.append((msg_idx, part_idx, part))

    if len(tool_results) &amp;lt;= KEEP_RECENT:
        return messages

    # 匹配 tool_use_id → 工具名
    tool_name_map = {}
    for msg in messages:
        if msg[&quot;role&quot;] == &quot;assistant&quot;:
            content = msg.get(&quot;content&quot;, [])
            if isinstance(content, list):
                for block in content:
                    if hasattr(block, &quot;type&quot;) and block.type == &quot;tool_use&quot;:
                        tool_name_map[block.id] = block.name

    # 清理旧的（保留最后 KEEP_RECENT 个），跳过 read_file
    to_clear = tool_results[:-KEEP_RECENT]
    for _, _, result in to_clear:
        if not isinstance(result.get(&quot;content&quot;), str) or len(result[&quot;content&quot;]) &amp;lt;= 100:
            continue  # 已经很短了，不处理
        tool_id = result.get(&quot;tool_use_id&quot;, &quot;&quot;)
        tool_name = tool_name_map.get(tool_id, &quot;unknown&quot;)
        if tool_name in PRESERVE_RESULT_TOOLS:
            continue  # read_file 结果保留，避免模型重读文件
        result[&quot;content&quot;] = f&quot;[Previous: used {tool_name}]&quot;

    return messages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键设计决策：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;保留最近 3 个&lt;/strong&gt; — 当前在做的事需要完整上下文，不压缩&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;read_file 永久保留&lt;/strong&gt; — 文件内容是参考材料，压缩后模型会忘了文件内容然后重读，反而不划算&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;替换而不是删除&lt;/strong&gt; — 结构保留（&lt;code&gt;tool_result&lt;/code&gt; 对象还在），只是内容变成占位符。模型能看到&quot;我之前调过 bash&quot;，但看不到 bash 的完整输出。这种&quot;知道发生了什么但忘了细节&quot;的状态，和人类记忆很像&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;长度 &amp;gt;100 的才压缩&lt;/strong&gt; — 短结果（比如 &quot;Wrote 50 bytes&quot;）不值得替换&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Layer 2：auto_compact——&quot;我记不住了，帮我总结一下&quot;&lt;/h3&gt;
&lt;p&gt;当 token 估算超过阈值（50000），触发自动压缩。&lt;strong&gt;用 LLM 总结 LLM 的对话&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;THRESHOLD = 50000
TRANSCRIPT_DIR = WORKDIR / &quot;.transcripts&quot;

def estimate_tokens(messages: list) -&amp;gt; int:
    &quot;&quot;&quot;粗略 token 估算：~4 个字符 ≈ 1 token&quot;&quot;&quot;
    return len(str(messages)) // 4

def auto_compact(messages: list) -&amp;gt; list:
    # 1. 先存盘，不丢数据
    TRANSCRIPT_DIR.mkdir(exist_ok=True)
    transcript_path = TRANSCRIPT_DIR / f&quot;transcript_{int(time.time())}.jsonl&quot;
    with open(transcript_path, &quot;w&quot;) as f:
        for msg in messages:
            f.write(json.dumps(msg, default=str) + &quot;\n&quot;)

    # 2. 取最后 80000 字符（防止总结请求本身超限），发给 LLM
    conversation_text = json.dumps(messages, default=str)[-80000:]

    # 3. LLM 总结（不带工具，纯文本总结）
    response = client.messages.create(
        model=MODEL,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;:
            &quot;Summarize this conversation for continuity. Include: &quot;
            &quot;1) What was accomplished, 2) Current state, 3) Key decisions made. &quot;
            &quot;Be concise but preserve critical details.\n\n&quot; + conversation_text}],
        max_tokens=2000,
    )

    summary = next((block.text for block in response.content if hasattr(block, &quot;text&quot;)), &quot;&quot;)

    # 4. 整个 messages 数组被替换为一条总结消息
    return [
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}&quot;},
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个细节值得注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;transcript_{timestamp}.jsonl&lt;/code&gt;&lt;/strong&gt; — 完整对话存盘到 &lt;code&gt;.transcripts/&lt;/code&gt;，以便后续 debug 或审查。信息没有丢失，只是移出了活跃上下文。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[-80000:]&lt;/code&gt;&lt;/strong&gt; — 取对话尾部分给 LLM 做总结。因为最近的对话最重要，旧的对话可能在之前的压缩中已经被总结过了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不带 tools 的 API 调用&lt;/strong&gt; — 这是 s06 中唯一一次不带 &lt;code&gt;tools&lt;/code&gt; 参数的调用。总结这件事不需要工具，模型只输出一段纯文本。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Layer 3：compact 工具——模型主动请求压缩&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 工具定义
{&quot;name&quot;: &quot;compact&quot;,
 &quot;description&quot;: &quot;Trigger manual conversation compression.&quot;,
 &quot;input_schema&quot;: {
     &quot;type&quot;: &quot;object&quot;,
     &quot;properties&quot;: {&quot;focus&quot;: {&quot;type&quot;: &quot;string&quot;,
         &quot;description&quot;: &quot;What to preserve in the summary&quot;}}}}

# dispatch map 中的 handler
&quot;compact&quot;: lambda **kw: &quot;Manual compression requested.&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型调用 &lt;code&gt;compact&lt;/code&gt; 工具后，循环中检测到 &lt;code&gt;manual_compact = True&lt;/code&gt;，同样调用 &lt;code&gt;auto_compact()&lt;/code&gt;。&lt;code&gt;focus&lt;/code&gt; 参数目前只是定义中的占位，实际只返回字符串 &lt;code&gt;&quot;Compressing...&quot;&lt;/code&gt;——真正的压缩逻辑和 Layer 2 共享同一个 &lt;code&gt;auto_compact&lt;/code&gt; 函数。&lt;/p&gt;
&lt;h3&gt;三层在循环中的位置&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def agent_loop(messages: list):
    while True:
        # Layer 1: 每轮静默执行
        micro_compact(messages)

        # Layer 2: 超过阈值自动触发
        if estimate_tokens(messages) &amp;gt; THRESHOLD:
            print(&quot;[auto_compact triggered]&quot;)
            messages[:] = auto_compact(messages)

        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})
        if response.stop_reason != &quot;tool_use&quot;:
            return

        results = []
        manual_compact = False
        for block in response.content:
            if block.type == &quot;tool_use&quot;:
                if block.name == &quot;compact&quot;:
                    manual_compact = True
                    output = &quot;Compressing...&quot;
                else:
                    handler = TOOL_HANDLERS.get(block.name)
                    output = handler(**block.input) if handler else f&quot;Unknown tool: {block.name}&quot;
                results.append({&quot;type&quot;: &quot;tool_result&quot;, &quot;tool_use_id&quot;: block.id, &quot;content&quot;: str(output)})

        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})

        # Layer 3: 模型调了 compact 后触发
        if manual_compact:
            print(&quot;[manual compact]&quot;)
            messages[:] = auto_compact(messages)
            return
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;三层金字塔的总结&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;层 1: micro_compact  ─  每轮、轻量、自动    ─  旧 tool_result → 占位符
层 2: auto_compact   ─  超 50000 token 触发  ─  全量对话 → LLM 总结
层 3: compact 工具   ─  模型主动调用        ─  同层 2，手动触发
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三层是递进关系：层 1 是日常清理，拖慢膨胀速度；层 2 是安全阀，防止越过 API 限制；层 3 是给模型的自主权，它可以在任务阶段切换时主动清空上下文。&lt;/p&gt;
&lt;h3&gt;s05 → s06 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s05&lt;/th&gt;
&lt;th&gt;s06&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具&lt;/td&gt;
&lt;td&gt;5 (基础 + load_skill)&lt;/td&gt;
&lt;td&gt;5 (基础 + compact)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上下文管理&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;三层压缩&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;循环变化&lt;/td&gt;
&lt;td&gt;dispatch 分发&lt;/td&gt;
&lt;td&gt;+ 层 1 前置检查 + 层 2 阈值检查 + 层 3 后置检查&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件系统&lt;/td&gt;
&lt;td&gt;skills/&lt;/td&gt;
&lt;td&gt;+ .transcripts/ 存档&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;模型可请求压缩&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;compact 工具&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s06_context_compact.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt（故意制造大量工具调用观察压缩）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Read every Python file in the agents/ directory one by one&lt;/code&gt; — 观察 micro-compact 逐步替换旧结果&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Keep reading files until compression triggers automatically&lt;/code&gt; — 触发 auto_compact&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Use the compact tool to manually compress the conversation&lt;/code&gt; — 手动触发&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s06 的三层压缩机制本质上是给 Agent &lt;strong&gt;可控的遗忘能力&lt;/strong&gt;。人类不会记住今天敲过的每一条命令的完整输出，只记住&quot;我刚才在干 X，结果是 Y&quot;。Agent 需要同样的能力。&lt;/p&gt;
&lt;p&gt;这里有一个反直觉的设计决策：&lt;strong&gt;read_file 的结果不压缩。&lt;/strong&gt; 原因用一句话说就是——&quot;忘掉 bash 输出没关系（可以重跑），忘掉文件内容会导致重复读文件，反复读文件反而更费 token&quot;。好的压缩策略不是无差别清理，而是知道什么值得保留。&lt;/p&gt;
&lt;p&gt;另一点：&lt;code&gt;auto_compact&lt;/code&gt; 里的总结请求是不带工具的 API 调用。这说明 &lt;strong&gt;Agent 的压缩能力本身也在 harness 层面，不在对话循环里&lt;/strong&gt;——压缩时模型不开着 bash/edit 等工具，它只用纯文本能力做总结。如果让压缩迭代跑到一半模型突然调了个 bash，那就不是压缩了。这是一种&quot;能力降级&quot;——在特定的 harness 路径上，工具集可以临时收紧。&lt;/p&gt;
&lt;h2&gt;7. s07：任务系统——&quot;比任何一次对话都长命的目标&quot;&lt;/h2&gt;
&lt;p&gt;s07 解决的是 s03 TodoManager 的两个致命弱点：&lt;strong&gt;内存态（压缩后丢失）&lt;/strong&gt; 和 &lt;strong&gt;扁平无依赖&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;s03 的 todo 列表在 Python 内存里，s06 的 auto_compact 一跑，整个消息历史被一条总结替换——todo 状态消失了。而且 todo 就是 &lt;code&gt;[ ]&lt;/code&gt; &lt;code&gt;[&amp;gt;]&lt;/code&gt; &lt;code&gt;[x]&lt;/code&gt; 三态，没有&quot;任务 B 依赖任务 A&quot;的能力。&lt;/p&gt;
&lt;p&gt;s07 的解法：&lt;strong&gt;把任务图持久化到磁盘上的 JSON 文件。&lt;/strong&gt; 每组文件构成一个带依赖关系的 DAG（有向无环图）。&lt;/p&gt;
&lt;h3&gt;(1) 磁盘上的任务图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.tasks/
  task_1.json  {&quot;id&quot;:1, &quot;subject&quot;:&quot;Set up project&quot;, &quot;status&quot;:&quot;completed&quot;}
  task_2.json  {&quot;id&quot;:2, &quot;subject&quot;:&quot;Write code&quot;, &quot;blockedBy&quot;:[1], &quot;status&quot;:&quot;pending&quot;}
  task_3.json  {&quot;id&quot;:3, &quot;subject&quot;:&quot;Write tests&quot;, &quot;blockedBy&quot;:[1], &quot;status&quot;:&quot;pending&quot;}
  task_4.json  {&quot;id&quot;:4, &quot;subject&quot;:&quot;Run CI&quot;, &quot;blockedBy&quot;:[2,3], &quot;status&quot;:&quot;pending&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应的有向图：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;               +----------+
          +--&amp;gt; | task 2   | --+
          |    | pending  |   |
+----------+  +----------+    +--&amp;gt; +----------+
| task 1   |                         | task 4   |
| completed| --&amp;gt; +----------+   +--&amp;gt; | blocked  |
+----------+     | task 3   | --+    +----------+
                 | pending  |
                 +----------+

顺序:  task 1 必须先完成, 才能开始 2 和 3
并行:  task 2 和 3 可以同时执行
依赖:  task 4 要等 2 和 3 都完成
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个语义非常清晰：&lt;strong&gt;什么能做&lt;/strong&gt;（pending 且 blockedBy 为空）、&lt;strong&gt;什么被卡住&lt;/strong&gt;（blockedBy 里还有未完成的 ID）、&lt;strong&gt;什么做完了&lt;/strong&gt;（completed）。&lt;/p&gt;
&lt;h3&gt;(2) TaskManager——CRUD + 依赖传播&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class TaskManager:
    def __init__(self, tasks_dir: Path):
        self.dir = tasks_dir
        self.dir.mkdir(exist_ok=True)
        self._next_id = self._max_id() + 1

    def _max_id(self) -&amp;gt; int:
        ids = [int(f.stem.split(&quot;_&quot;)[1]) for f in self.dir.glob(&quot;task_*.json&quot;)]
        return max(ids) if ids else 0

    def _load(self, task_id: int) -&amp;gt; dict:
        path = self.dir / f&quot;task_{task_id}.json&quot;
        return json.loads(path.read_text())

    def _save(self, task: dict):
        path = self.dir / f&quot;task_{task[&apos;id&apos;]}.json&quot;
        path.write_text(json.dumps(task, indent=2, ensure_ascii=False))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;s07&lt;/code&gt; 是第二个用到文件系统持久化的 session（第一个是 s06 的 &lt;code&gt;.transcripts/&lt;/code&gt;）。&lt;code&gt;_next_id&lt;/code&gt; 从已有文件中读取最大值 +1——进程重启后 ID 不冲突。注意它不是靠全局计数器或者自增序列，而是 &lt;code&gt;glob(&quot;task_*.json&quot;)&lt;/code&gt; 扫描磁盘，&lt;strong&gt;文件系统本身就是状态存储&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;create&lt;/code&gt; 方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def create(self, subject: str, description: str = &quot;&quot;) -&amp;gt; str:
    task = {
        &quot;id&quot;: self._next_id, &quot;subject&quot;: subject, &quot;description&quot;: description,
        &quot;status&quot;: &quot;pending&quot;, &quot;blockedBy&quot;: [], &quot;owner&quot;: &quot;&quot;,
    }
    self._save(task)
    self._next_id += 1
    return json.dumps(task, indent=2, ensure_ascii=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;owner 字段现在是空字符串——这将来在 s09-s11 的 Agent 团队中会用到，标记任务属于哪个 Agent。&lt;/p&gt;
&lt;h3&gt;(3) 依赖传播——完成即解锁&lt;/h3&gt;
&lt;p&gt;这是 s07 最精巧的机制。当任务完成时，&lt;strong&gt;自动&lt;/strong&gt;从所有其他任务的 &lt;code&gt;blockedBy&lt;/code&gt; 中移除已完成的任务 ID：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _clear_dependency(self, completed_id: int):
    &quot;&quot;&quot;Remove completed_id from ALL other tasks&apos; blockedBy lists.&quot;&quot;&quot;
    for f in self.dir.glob(&quot;task_*.json&quot;):
        task = json.loads(f.read_text())
        if completed_id in task.get(&quot;blockedBy&quot;, []):
            task[&quot;blockedBy&quot;].remove(completed_id)
            self._save(task)

def update(self, task_id: int, status: str = None,
           add_blocked_by: list = None, remove_blocked_by: list = None) -&amp;gt; str:
    task = self._load(task_id)
    if status:
        if status not in (&quot;pending&quot;, &quot;in_progress&quot;, &quot;completed&quot;):
            raise ValueError(f&quot;Invalid status: {status}&quot;)
        task[&quot;status&quot;] = status
        if status == &quot;completed&quot;:
            self._clear_dependency(task_id)    # ← 关键：标记完成时级联解锁
    if add_blocked_by:
        task[&quot;blockedBy&quot;] = list(set(task[&quot;blockedBy&quot;] + add_blocked_by))
    if remove_blocked_by:
        task[&quot;blockedBy&quot;] = [x for x in task[&quot;blockedBy&quot;] if x not in remove_blocked_by]
    self._save(task)
    return json.dumps(task, indent=2, ensure_ascii=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有一个重要的设计：&lt;code&gt;_clear_dependency&lt;/code&gt; 扫描&lt;strong&gt;全部&lt;/strong&gt;任务文件，而不是被完成的那个任务自己反查。这样可以安全处理&quot;任务 A 被任务 B、C、D 共同依赖&quot;的情况——A 完成那一刻，B、C、D 的 blockedBy 都被清理。&lt;/p&gt;
&lt;p&gt;此外，&lt;code&gt;add_blocked_by&lt;/code&gt; 用 &lt;code&gt;list(set(...))&lt;/code&gt; 去重，防止同一个依赖被加两次。&lt;/p&gt;
&lt;h3&gt;(4) 四个 task 工具&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {
    &quot;bash&quot;:        lambda **kw: run_bash(kw[&quot;command&quot;]),
    &quot;read_file&quot;:   lambda **kw: run_read(kw[&quot;path&quot;], kw.get(&quot;limit&quot;)),
    &quot;write_file&quot;:  lambda **kw: run_write(kw[&quot;path&quot;], kw[&quot;content&quot;]),
    &quot;edit_file&quot;:   lambda **kw: run_edit(kw[&quot;path&quot;], kw[&quot;old_text&quot;], kw[&quot;new_text&quot;]),
    &quot;task_create&quot;: lambda **kw: TASKS.create(kw[&quot;subject&quot;], kw.get(&quot;description&quot;, &quot;&quot;)),
    &quot;task_update&quot;: lambda **kw: TASKS.update(kw[&quot;task_id&quot;], kw.get(&quot;status&quot;),
                                              kw.get(&quot;addBlockedBy&quot;), kw.get(&quot;removeBlockedBy&quot;)),
    &quot;task_list&quot;:   lambda **kw: TASKS.list_all(),
    &quot;task_get&quot;:    lambda **kw: TASKS.get(kw[&quot;task_id&quot;]),
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;四个工具的职责很明确：增、改、列、查。注意没有删除——任务完成了就是标记为 completed，留下痕迹。&lt;/p&gt;
&lt;h3&gt;(5) s03 TodoWrite vs s07 TaskManager 对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;s03 TodoWrite&lt;/th&gt;
&lt;th&gt;s07 TaskManager&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;存储&lt;/td&gt;
&lt;td&gt;Python 内存&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.tasks/&lt;/code&gt; 磁盘 JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;持久性&lt;/td&gt;
&lt;td&gt;进程内&lt;/td&gt;
&lt;td&gt;跨进程重启&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;依赖关系&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blockedBy&lt;/code&gt; 有向图&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;压缩安全性&lt;/td&gt;
&lt;td&gt;丢失（在 messages 里）&lt;/td&gt;
&lt;td&gt;存活（在文件系统里）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;字段&lt;/td&gt;
&lt;td&gt;id, text, status&lt;/td&gt;
&lt;td&gt;id, subject, description, status, blockedBy, owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;并发&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;owner 字段（为 s09+ 准备）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(6) 为什么是&quot;第二个关键枢纽&quot;&lt;/h3&gt;
&lt;p&gt;s07 在整个 12 个 session 序列中处于中点位置（&lt;code&gt;s01-s06 | s07-s12&lt;/code&gt;），文档特别用 &lt;code&gt;|&lt;/code&gt; 分隔。这不是偶然的——&lt;strong&gt;s07 是一切合作的骨架&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;s08&lt;/strong&gt; 的后台线程读取任务列表，自动认领 pending 任务&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;s09-s10&lt;/strong&gt; 的 Agent 团队通过 &lt;code&gt;owner&lt;/code&gt; 字段协商任务分配&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;s12&lt;/strong&gt; 的 worktree 隔离用任务 ID 绑定工作目录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;任务图是&quot;被动的数据&quot;，但它解耦了生产者和消费者——Agent A 创建任务，Agent B 执行任务，它们不需要直接通信，只需要读写同一个 &lt;code&gt;.tasks/&lt;/code&gt; 目录。&lt;/p&gt;
&lt;h3&gt;(7) s06 → s07 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s06&lt;/th&gt;
&lt;th&gt;s07&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具数&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;8 (+4 task)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;持久化&lt;/td&gt;
&lt;td&gt;.transcripts/（只存档）&lt;/td&gt;
&lt;td&gt;.tasks/（活跃状态）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;规划引擎&lt;/td&gt;
&lt;td&gt;无（s06 没带 todo）&lt;/td&gt;
&lt;td&gt;TaskManager + DAG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;依赖关系&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;blockedBy 自动传播&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;循环变化&lt;/td&gt;
&lt;td&gt;三层压缩&lt;/td&gt;
&lt;td&gt;回到简单 dispatch（压缩暂未整合）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(8) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s07_task_system.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Create 3 tasks: &quot;Setup project&quot;, &quot;Write code&quot;, &quot;Write tests&quot;. Make them depend on each other in order.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;List all tasks and show the dependency graph&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Complete task 1 and then list tasks to see task 2 unblocked&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Create a task board for refactoring: parse → transform → emit → test, where transform and emit can run in parallel after parse&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;试试关掉进程再重开，调用 task_list——任务还在磁盘上。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s07 的核心思想一句话：&lt;strong&gt;状态在对话之外。&lt;/strong&gt; s03 的 todo 在 messages 里（压缩后消失），s07 的 task 在文件系统里（压缩后还在）。这是从&quot;对话级 Agent&quot;迈向&quot;项目级 Agent&quot;的一道门槛——对话可以结束，任务可以继续。&lt;/p&gt;
&lt;p&gt;从架构层面看，&lt;code&gt;_clear_dependency&lt;/code&gt; 是一次&lt;strong&gt;被动传播&lt;/strong&gt;：不是模型主动说&quot;现在任务 B 的 blockedBy 可以移除了&quot;，而是 harness 在任务 A 标记为 completed 那一刻自动做了级联更新。模型只需要知道&quot;某件事做完了&quot;，harness 负责把&quot;做完&quot;这件事的后果传到所有相关节点。这就是 harness 比 prompt 强的根本原因——harness 能做一致性的级联操作，prompt 只能做文本建议。&lt;/p&gt;
&lt;h2&gt;8. s08：后台任务——&quot;慢操作丢后台，Agent 继续想下一步&quot;&lt;/h2&gt;
&lt;p&gt;s08 解决的是 Agent 的 I/O 阻塞问题。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;npm install&lt;/code&gt; 跑 3 分钟、&lt;code&gt;pytest&lt;/code&gt; 跑 2 分钟、&lt;code&gt;docker build&lt;/code&gt; 跑 5 分钟——s04 的 &lt;code&gt;run_subagent&lt;/code&gt; 和普通的 bash 都是同步阻塞的，Agent 只能干等。用户说&quot;装依赖 + 顺便建个配置文件&quot;，Agent 得一个一个来。&lt;/p&gt;
&lt;p&gt;s08 的解法：&lt;strong&gt;后台线程 + 通知队列。模型 spawn 任务后立即拿到 task_id，继续干别的事；任务完成后结果注入下一轮对话。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(1) BackgroundManager——线程池的朴素版&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class BackgroundManager:
    def __init__(self):
        self.tasks = {}                   # task_id → {status, result, command}
        self._notification_queue = []     # 完成的任务结果
        self._lock = threading.Lock()     # 线程安全
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不是线程池——就是 &lt;code&gt;threading.Thread&lt;/code&gt; 每次新建一个线程。daemon 线程，主进程退出时自动终止。&lt;/p&gt;
&lt;h3&gt;(2) &lt;code&gt;run()&lt;/code&gt;——启动即返回&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def run(self, command: str) -&amp;gt; str:
    task_id = str(uuid.uuid4())[:8]       # 8 位随机 ID
    self.tasks[task_id] = {
        &quot;status&quot;: &quot;running&quot;, &quot;result&quot;: None, &quot;command&quot;: command
    }
    thread = threading.Thread(
        target=self._execute, args=(task_id, command), daemon=True
    )
    thread.start()
    return f&quot;Background task {task_id} started: {command[:80]}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键：&lt;strong&gt;函数立即返回&lt;/strong&gt;，模型看到一个 task_id，可以接着干别的事。和 s04 的 &lt;code&gt;run_subagent&lt;/code&gt; 完全不同——那个是同步阻塞直到子 Agent 完成。&lt;/p&gt;
&lt;h3&gt;(3) &lt;code&gt;_execute()&lt;/code&gt;——线程内的 subprocess&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def _execute(self, task_id: str, command: str):
    try:
        r = subprocess.run(
            command, shell=True, cwd=WORKDIR,
            capture_output=True, text=True, timeout=300    # 5分钟超时
        )
        output = (r.stdout + r.stderr).strip()[:50000]
        status = &quot;completed&quot;
    except subprocess.TimeoutExpired:
        output = &quot;Error: Timeout (300s)&quot;
        status = &quot;timeout&quot;
    except Exception as e:
        output = f&quot;Error: {e}&quot;
        status = &quot;error&quot;

    self.tasks[task_id][&quot;status&quot;] = status
    self.tasks[task_id][&quot;result&quot;] = output or &quot;(no output)&quot;

    # 线程安全地推入通知队列
    with self._lock:
        self._notification_queue.append({
            &quot;task_id&quot;: task_id, &quot;status&quot;: status,
            &quot;command&quot;: command[:80], &quot;result&quot;: (output or &quot;(no output)&quot;)[:500],
        })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;和 &lt;code&gt;run_bash&lt;/code&gt; 几乎一样——&lt;code&gt;subprocess.run&lt;/code&gt; + 超时 + 截断。区别只有两个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;timeout 从 120s 变成 300s&lt;/strong&gt; — 后台任务预期是长任务，给了更长的超时&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结果推入通知队列而不是直接返回&lt;/strong&gt; — 线程不能直接往 messages 里写，所以走队列&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(4) &lt;code&gt;drain_notifications()&lt;/code&gt;——循环中唯一的线程交汇点&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def drain_notifications(self) -&amp;gt; list:
    with self._lock:
        notifs = list(self._notification_queue)
        self._notification_queue.clear()
    return notifs

def check(self, task_id: str = None) -&amp;gt; str:
    &quot;&quot;&quot;查询单个任务状态或列出所有&quot;&quot;&quot;
    if task_id:
        t = self.tasks.get(task_id)
        if not t:
            return f&quot;Error: Unknown task {task_id}&quot;
        return f&quot;[{t[&apos;status&apos;]}] {t[&apos;command&apos;][:60]}\n{t.get(&apos;result&apos;) or &apos;(running)&apos;}&quot;
    # 列出所有
    lines = []
    for tid, t in self.tasks.items():
        lines.append(f&quot;{tid}: [{t[&apos;status&apos;]}] {t[&apos;command&apos;][:60]}&quot;)
    return &quot;\n&quot;.join(lines) if lines else &quot;No background tasks.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;drain_notifications&lt;/code&gt; 是一次性操作——取走所有待通知，清空队列。这保证了每条通知只被注入一次。&lt;/p&gt;
&lt;h3&gt;(5) 循环注入——LLM 调用前的&quot;收件箱检查&quot;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def agent_loop(messages: list):
    while True:
        # 每次 LLM 调用前，清空通知队列
        notifs = BG.drain_notifications()
        if notifs and messages:
            notif_text = &quot;\n&quot;.join(
                f&quot;[bg:{n[&apos;task_id&apos;]}] {n[&apos;status&apos;]}: {n[&apos;result&apos;]}&quot;
                for n in notifs
            )
            messages.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: f&quot;&amp;lt;background-results&amp;gt;\n{notif_text}\n&amp;lt;/background-results&amp;gt;&quot;
            })

        response = client.messages.create(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型的核心循环是单线程的——&lt;strong&gt;只有 subprocess 在后台线程跑，agent loop 本身不并发&lt;/strong&gt;。流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Round N:   模型调 background_run(&quot;npm install&quot;) → 拿到 task_id
Round N+1: 模型干别的事（比如 background_run(&quot;pip install&quot;) 或 read_file）
Round N+2: drain_notifications() 发现 npm 跑完了 → 作为 &amp;lt;background-results&amp;gt; 注入
           模型看到结果，决定下一步
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不叫 agent 并发思考，这叫 &lt;strong&gt;I/O 并行 + Agent 顺序执行&lt;/strong&gt;。Agent 本身还是单线程地一轮轮走，只是等待 I/O 的时间被利用了。&lt;/p&gt;
&lt;h3&gt;(6) 工具定义&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 两个新工具
{&quot;name&quot;: &quot;background_run&quot;, &quot;description&quot;: &quot;Run command in background thread. Returns task_id immediately.&quot;,
 &quot;input_schema&quot;: {&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: {&quot;command&quot;: {&quot;type&quot;: &quot;string&quot;}}, &quot;required&quot;: [&quot;command&quot;]}},

{&quot;name&quot;: &quot;check_background&quot;, &quot;description&quot;: &quot;Check background task status. Omit task_id to list all.&quot;,
 &quot;input_schema&quot;: {&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: {&quot;task_id&quot;: {&quot;type&quot;: &quot;string&quot;}}}},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意 &lt;code&gt;check_background&lt;/code&gt; 的 &lt;code&gt;task_id&lt;/code&gt; 不是 required——省略时列出所有任务。模型可以不知道自己 spawn 了哪些任务，调一次 &lt;code&gt;check_background()&lt;/code&gt; 就能看到全局。&lt;/p&gt;
&lt;h3&gt;(7) s07 → s08 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s07&lt;/th&gt;
&lt;th&gt;s08&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具数&lt;/td&gt;
&lt;td&gt;8 (4 task + 4 base)&lt;/td&gt;
&lt;td&gt;6 (2 bg + 4 base，task 工具暂未整合)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;执行方式&lt;/td&gt;
&lt;td&gt;仅阻塞&lt;/td&gt;
&lt;td&gt;阻塞 + 后台线程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;通知机制&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;每轮排空通知队列&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;并发模型&lt;/td&gt;
&lt;td&gt;纯串行&lt;/td&gt;
&lt;td&gt;I/O 并行、Agent 顺序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;循环变化&lt;/td&gt;
&lt;td&gt;dispatch 分发&lt;/td&gt;
&lt;td&gt;+ drain_notifications 前置注入&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(8) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s08_background_tasks.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Run &quot;sleep 5 &amp;amp;&amp;amp; echo done&quot; in the background, then create a file while it runs&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Start 3 background tasks: &quot;sleep 2&quot;, &quot;sleep 4&quot;, &quot;sleep 6&quot;. Check their status.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s08 引入了 harness 中第一个真正异步的组件，但保持了 Agent 循环的单线程心智模型。这其实是一个重要的架构选择：&lt;strong&gt;模型不需要理解线程——它只知道&quot;我上次 spawn 了一个东西，现在收到了它的结果&quot;。&lt;/strong&gt; 后台线程是 harness 层的事，模型的思维还是线性的。&lt;/p&gt;
&lt;p&gt;另外值得注意：s04 的 &lt;code&gt;run_subagent&lt;/code&gt; 是同步的，为什么不用后台线程包装它？因为子 Agent 需要的是&quot;上下文隔离&quot;，不是&quot;执行并行&quot;——父 Agent 在等子 Agent 的结论才能继续。而后台任务 (&lt;code&gt;npm install&lt;/code&gt;) 没有这种依赖关系，模型可以继续干别的事。这就是两种异步的不同：一个是&quot;我不等你，我干别的&quot;，一个是&quot;我要你的结果才能继续&quot;。&lt;/p&gt;
&lt;h2&gt;9. s09：Agent 团队——&quot;多个模型，通过文件协调&quot;&lt;/h2&gt;
&lt;p&gt;s09 是从单 Agent 到多 Agent 的一道门槛。在此之前的所有 session 都是&quot;一个模型、一个 loop&quot;，s04 的子 Agent 是一次性的生成-返回-销毁，s08 的后台任务只能跑 shell 不能做 LLM 决策。&lt;/p&gt;
&lt;p&gt;s09 引入了三个新能力：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;持久化队友&lt;/strong&gt; — 有名字、有角色、有状态，跨多轮存活，不是一次性&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;文件邮箱通信&lt;/strong&gt; — append-only JSONL 收件箱，Agent 之间发消息&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个队友独立 agent loop&lt;/strong&gt; — 每个人在自己的线程里跑完整的 while-tool_use 循环&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;(1) s04 Subagent vs s09 Teammate&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Subagent (s04):   spawn → execute → return summary → destroyed
Teammate (s09):   spawn → working → idle → working → ... → shutdown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;s04 的子 Agent 像函数调用——传参、执行、返回、清理。s09 的队友像&lt;strong&gt;员工&lt;/strong&gt;——有名字 &quot;alice&quot;，有角色 &quot;coder&quot;，有生命周期 &lt;code&gt;working → idle → working → idle&lt;/code&gt;，可以反复复派任务。&lt;/p&gt;
&lt;h3&gt;(2) 目录结构&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.team/
  config.json              # 团队名册 + 各成员状态
  inbox/
    alice.jsonl            # alice 的收件箱（append-only）
    bob.jsonl              # bob 的收件箱
    lead.jsonl             # 领导的收件箱
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) TeammateManager——队友生命周期&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class TeammateManager:
    def __init__(self, team_dir: Path):
        self.dir = team_dir
        self.dir.mkdir(exist_ok=True)
        self.config_path = self.dir / &quot;config.json&quot;
        self.config = self._load_config()   # 从磁盘恢复
        self.threads = {}                    # 名字 → 线程

    def _load_config(self) -&amp;gt; dict:
        if self.config_path.exists():
            return json.loads(self.config_path.read_text())
        return {&quot;team_name&quot;: &quot;default&quot;, &quot;members&quot;: []}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;config.json&lt;/code&gt; 每次更新都 &lt;code&gt;_save_config()&lt;/code&gt; 写回磁盘。进程重启后队员名册还在。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;spawn()&lt;/code&gt; 方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def spawn(self, name: str, role: str, prompt: str) -&amp;gt; str:
    member = self._find_member(name)
    if member:
        if member[&quot;status&quot;] not in (&quot;idle&quot;, &quot;shutdown&quot;):
            return f&quot;Error: &apos;{name}&apos; is currently {member[&apos;status&apos;]}&quot;
        member[&quot;status&quot;] = &quot;working&quot;
    else:
        member = {&quot;name&quot;: name, &quot;role&quot;: role, &quot;status&quot;: &quot;working&quot;}
        self.config[&quot;members&quot;].append(member)
    self._save_config()
    thread = threading.Thread(
        target=self._teammate_loop,
        args=(name, role, prompt),
        daemon=True,
    )
    self.threads[name] = thread
    thread.start()
    return f&quot;Spawned &apos;{name}&apos; (role: {role})&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果同名队友处于 &lt;code&gt;idle&lt;/code&gt; 状态，就唤醒它并给新 prompt；如果是新名字，创建并启动线程。这实现了&quot;队友复用&quot;——不需要每次都创建新 Agent。&lt;/p&gt;
&lt;h3&gt;(4) MessageBus——文件级通信协议&lt;/h3&gt;
&lt;p&gt;这是 s09 最核心的发明。JSONL 文件做邮箱：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MessageBus:
    def __init__(self, inbox_dir: Path):
        self.dir = inbox_dir
        self.dir.mkdir(parents=True, exist_ok=True)

    def send(self, sender: str, to: str, content: str,
             msg_type: str = &quot;message&quot;, extra: dict = None) -&amp;gt; str:
        if msg_type not in VALID_MSG_TYPES:
            return f&quot;Error: Invalid type &apos;{msg_type}&apos;&quot;
        msg = {
            &quot;type&quot;: msg_type, &quot;from&quot;: sender,
            &quot;content&quot;: content, &quot;timestamp&quot;: time.time(),
        }
        if extra:
            msg.update(extra)
        inbox_path = self.dir / f&quot;{to}.jsonl&quot;
        with open(inbox_path, &quot;a&quot;) as f:        # ← append-only
            f.write(json.dumps(msg) + &quot;\n&quot;)
        return f&quot;Sent {msg_type} to {to}&quot;

    def read_inbox(self, name: str) -&amp;gt; list:
        inbox_path = self.dir / f&quot;{name}.jsonl&quot;
        if not inbox_path.exists():
            return []
        messages = []
        for line in inbox_path.read_text().strip().splitlines():
            if line:
                messages.append(json.loads(line))
        inbox_path.write_text(&quot;&quot;)               # ← drain after read
        return messages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键设计：&lt;strong&gt;读即清空&lt;/strong&gt;。&lt;code&gt;read_inbox&lt;/code&gt; → 读所有行 → &lt;code&gt;write_text(&quot;&quot;)&lt;/code&gt; 删文件。每条消息只被消费一次，不会重复处理。&lt;/p&gt;
&lt;p&gt;5 种消息类型（定义了但 s09 只用到前 2 种，后 3 种留给 s10）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VALID_MSG_TYPES = {
    &quot;message&quot;,              # 普通文本消息
    &quot;broadcast&quot;,            # 发给所有人
    &quot;shutdown_request&quot;,     # 请求关闭 (s10)
    &quot;shutdown_response&quot;,    # 同意/拒绝关闭 (s10)
    &quot;plan_approval_response&quot;, # 审批计划 (s10)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还有 &lt;code&gt;broadcast&lt;/code&gt; 方法，遍历所有队友逐个发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def broadcast(self, sender: str, content: str, teammates: list) -&amp;gt; str:
    count = 0
    for name in teammates:
        if name != sender:
            self.send(sender, name, content, &quot;broadcast&quot;)
            count += 1
    return f&quot;Broadcast to {count} teammates&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(5) 队友的 agent loop——缩水但完整的版本&lt;/h3&gt;
&lt;p&gt;每个队友在自己的线程里跑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _teammate_loop(self, name: str, role: str, prompt: str):
    sys_prompt = (
        f&quot;You are &apos;{name}&apos;, role: {role}, at {WORKDIR}. &quot;
        f&quot;Use send_message to communicate. Complete your task.&quot;
    )
    messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}]
    tools = self._teammate_tools()   # bash/read/write/edit/send_message/read_inbox
    for _ in range(50):              # 安全限制 50 轮
        # 每轮检查收件箱
        inbox = BUS.read_inbox(name)
        for msg in inbox:
            messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: json.dumps(msg)})

        response = client.messages.create(
            model=MODEL, system=sys_prompt,
            messages=messages, tools=tools, max_tokens=8000,
        )
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})
        if response.stop_reason != &quot;tool_use&quot;:
            break
        # 执行工具（通过 _exec 分发）
        results = []
        for block in response.content:
            if block.type == &quot;tool_use&quot;:
                output = self._exec(name, block.name, block.input)
                results.append({
                    &quot;type&quot;: &quot;tool_result&quot;, &quot;tool_use_id&quot;: block.id,
                    &quot;content&quot;: str(output),
                })
        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})

    # 完成后回到 idle 状态
    member = self._find_member(name)
    if member and member[&quot;status&quot;] != &quot;shutdown&quot;:
        member[&quot;status&quot;] = &quot;idle&quot;
        self._save_config()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意队友的 &lt;code&gt;sys_prompt&lt;/code&gt; 和 Leader 不同——队友被告知自己的名字和角色，被要求&quot;完成你的任务&quot;，Leader 则被告知&quot;你是团队领导，派发任务&quot;。&lt;/p&gt;
&lt;h3&gt;(6) 领导（Lead）的循环——收件箱注入&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def agent_loop(messages: list):
    while True:
        # 每轮先检查收件箱
        inbox = BUS.read_inbox(&quot;lead&quot;)
        if inbox:
            messages.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: f&quot;&amp;lt;inbox&amp;gt;{json.dumps(inbox, indent=2)}&amp;lt;/inbox&amp;gt;&quot;,
            })
        # 正常 loop...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;领导和队友的通信模式是对称的——都走 &lt;code&gt;BUS.read_inbox&lt;/code&gt;，都通过 JSONL 文件交换消息。领导给 alice 发消息 → &lt;code&gt;alice.jsonl&lt;/code&gt; 新增一行 → alice 下轮 &lt;code&gt;read_inbox&lt;/code&gt; 读到 → 清空。&lt;/p&gt;
&lt;h3&gt;(7) 九工具全貌&lt;/h3&gt;
&lt;p&gt;Leader 有 9 个工具：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {
    &quot;bash&quot;:            ...,  # 基础工具
    &quot;read_file&quot;:       ...,
    &quot;write_file&quot;:      ...,
    &quot;edit_file&quot;:       ...,
    &quot;spawn_teammate&quot;:  ...,  # 创建/唤醒队友
    &quot;list_teammates&quot;:  ...,  # 列出团队状态
    &quot;send_message&quot;:    ...,  # 点对点发消息
    &quot;read_inbox&quot;:      ...,  # 读 lead 的收件箱
    &quot;broadcast&quot;:       ...,  # 广播给全员
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leader 有 bash/edit 等完整能力（它可以亲自干活），也有团队管理能力。队友只有 6 个工具——没有 &lt;code&gt;spawn_teammate&lt;/code&gt; / &lt;code&gt;list_teammates&lt;/code&gt; / &lt;code&gt;broadcast&lt;/code&gt;（防止递归管理）。&lt;/p&gt;
&lt;h3&gt;(7.5) 什么时候用哪种模式？&lt;/h3&gt;
&lt;p&gt;到 s09 为止，我们已经有了三种 Agent 协作模式。怎么选？代码没有显式写决策逻辑，但从设计意图可以看出一个判断框架：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;单 Agent (s01-s02)&lt;/th&gt;
&lt;th&gt;Subagent (s04)&lt;/th&gt;
&lt;th&gt;Agent 团队 (s09)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;适用场景&lt;/td&gt;
&lt;td&gt;简单任务，几步完成&lt;/td&gt;
&lt;td&gt;探索/搜索，需要上下文隔离&lt;/td&gt;
&lt;td&gt;复杂多步任务，可并行&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;任务特征&lt;/td&gt;
&lt;td&gt;单一目标，线性执行&lt;/td&gt;
&lt;td&gt;读多文件但只需结论&lt;/td&gt;
&lt;td&gt;角色有分工 (coder/tester)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;生命周期&lt;/td&gt;
&lt;td&gt;一次性对话&lt;/td&gt;
&lt;td&gt;spawn→执行→返回→销毁&lt;/td&gt;
&lt;td&gt;spawn→work→idle→work→...&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;通信&lt;/td&gt;
&lt;td&gt;无（用户↔Agent）&lt;/td&gt;
&lt;td&gt;单向：父→子 prompt，子→父 summary&lt;/td&gt;
&lt;td&gt;双向：JSONL 收件箱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上下文&lt;/td&gt;
&lt;td&gt;父对话共享&lt;/td&gt;
&lt;td&gt;子独立上下文（隔离）&lt;/td&gt;
&lt;td&gt;各自独立上下文&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;并发&lt;/td&gt;
&lt;td&gt;串行&lt;/td&gt;
&lt;td&gt;串行（父阻塞等子）&lt;/td&gt;
&lt;td&gt;并行（各自线程）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;典型 prompt&lt;/td&gt;
&lt;td&gt;&quot;列出所有 py 文件&quot;&lt;/td&gt;
&lt;td&gt;&quot;找一下这个项目用什么测试框架&quot;&lt;/td&gt;
&lt;td&gt;&quot;alice 写代码，bob 写测试&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;决策逻辑模型（LLM 自己判断）：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;模型看到任务后，会基于自己的判断决定调哪个工具：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要自己查文件但不想污染对话 → 调 &lt;code&gt;task&lt;/code&gt; 工具 spawn 一个子 Agent&lt;/li&gt;
&lt;li&gt;任务可以分给不同角色并行 → 调 &lt;code&gt;spawn_teammate&lt;/code&gt; 创建队友&lt;/li&gt;
&lt;li&gt;简单的读/写/改 → 直接用 bash / read_file / write_file / edit_file&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;harness 不替模型做这个决策。它只是&lt;strong&gt;把三种工具都提供出来，让模型自己判断场景&lt;/strong&gt;。这和之前的原则一致——模型拥有判断权，harness 拥有执行权。&lt;/p&gt;
&lt;p&gt;值得一提的是：这些模式不是互斥的。Leader 可以 spawn 一个 teammate（alice），alice 在处理任务时也可以在自己的 loop 里用 bash/read/write——团队模式是单 Agent 模式的超集，团队里的每个成员本质上还是一个独立 Agent。&lt;/p&gt;
&lt;h3&gt;(8) s08 → s09 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s08&lt;/th&gt;
&lt;th&gt;s09&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent 数量&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1 Lead + N 队友&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具数&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;9 (+spawn/send/read_inbox/broadcast，同时也有 check)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;持久化&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;config.json + JSONL 收件箱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;线程&lt;/td&gt;
&lt;td&gt;跑 shell&lt;/td&gt;
&lt;td&gt;跑完整 agent loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;通信&lt;/td&gt;
&lt;td&gt;无（通知队列是单向）&lt;/td&gt;
&lt;td&gt;双向文件邮箱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;生命周期&lt;/td&gt;
&lt;td&gt;一次性守护线程&lt;/td&gt;
&lt;td&gt;idle ↔ working 循环&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(9) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s09_agent_teams.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内置命令（非 LLM 路径）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/team&lt;/code&gt; — 直接查看 &lt;code&gt;.team/config.json&lt;/code&gt; 中的团队名册&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/inbox&lt;/code&gt; — 直接查看 lead 的收件箱&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;推荐测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Spawn alice (coder) and bob (tester). Have alice send bob a message.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Broadcast &quot;status update: phase 1 complete&quot; to all teammates&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s09 用最简单的通信原语——&lt;strong&gt;文件追加 + 读后清空&lt;/strong&gt;——实现了多 Agent 协作。没有消息队列、没有 RPC、没有 WebSocket。JSONL 收件箱就是一个单写者多读者、append-only 的日志。&lt;/p&gt;
&lt;p&gt;这个设计有两个极简主义洞察：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;文件即协议&lt;/strong&gt; — 不需要定义通信协议格式，JSONL 每一行就是一条消息。没有握手、没有 ack、没有重试。读即清空 = 消息确认（如果进程在读后崩溃前没处理完，消息会丢——但对 Agent 来说，丢消息不是故障，它会在下一轮收到新消息时继续工作）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;收件箱读清是幂等屏障&lt;/strong&gt; — &lt;code&gt;read_inbox&lt;/code&gt; 返回后文件为空。这意味着一个队友同一时间只有一个线程在消费它的收件箱（因为只有一个 teammate loop）。没有锁竞争，没有重复消费。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对比 s04 的 subagent，s09 的队友和 subagent 有本质不同：subagent 共享文件系统但不共享通信通道；teammate 通过收件箱随时可以收到新任务。&lt;strong&gt;通信通道是 Agent 从&quot;工具&quot;升级为&quot;成员&quot;的分界线。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;10. s10：团队协议——&quot;模型之间的结构化握手&quot;&lt;/h2&gt;
&lt;p&gt;s09 的队友能干活能通信，但缺少两样东西：&lt;strong&gt;优雅关机&lt;/strong&gt;和&lt;strong&gt;计划审批&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;直接杀线程会留下写了一半的文件、过期的 config.json。高风险变更（&quot;重构认证模块&quot;）队友拿到就开干，没有审批环节。s10 用一个统一的模式解决这两个问题：&lt;strong&gt;request_id 关联 + 两态 FSM&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;(1) 统一的 FSM——一个模式，两个场景&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Shutdown Protocol                  Plan Approval Protocol
==================                 ======================
Lead             Teammate           Teammate           Lead
  |                 |                 |                 |
  |--shutdown_req--&amp;gt;|                 |--plan_req------&amp;gt;|
  | {req_id:&quot;abc&quot;}  |                 | {req_id:&quot;xyz&quot;}  |
  |                 |                 |                 |
  |&amp;lt;--shutdown_resp-|                 |&amp;lt;--plan_resp-----|
  | {req_id:&quot;abc&quot;,  |                 | {req_id:&quot;xyz&quot;,  |
  |  approve:true}  |                 |  approve:true}  |

共享状态机:  [pending] ──approve──&amp;gt; [approved]
            [pending] ──reject───&amp;gt; [rejected]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个场景方向不同但结构完全一样：一方发带唯一 ID 的请求，另一方引用同一 ID 响应。&lt;/p&gt;
&lt;h3&gt;(2) 请求追踪器——全局状态&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 全局字典，用 request_id 做 key
shutdown_requests = {}   # {req_id: {target|from: name, status: &quot;pending&quot;|&quot;approved&quot;|&quot;rejected&quot;}}
plan_requests = {}        # {req_id: {from: name, plan: text, status: ...}}
_tracker_lock = threading.Lock()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个全局 dict 在 s09 的基础上加了一层&lt;strong&gt;状态可观测性&lt;/strong&gt;——s09 发完消息就完了，不知道对方处理了没有。s10 通过 request_id 可以查到每笔请求的状态。&lt;/p&gt;
&lt;h3&gt;(3) 关机协议——&quot;请停下手里的活&quot;&lt;/h3&gt;
&lt;p&gt;Leader 发起：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def handle_shutdown_request(teammate: str) -&amp;gt; str:
    req_id = str(uuid.uuid4())[:8]
    with _tracker_lock:
        shutdown_requests[req_id] = {&quot;target&quot;: teammate, &quot;status&quot;: &quot;pending&quot;}
    BUS.send(
        &quot;lead&quot;, teammate, &quot;Please shut down gracefully.&quot;,
        &quot;shutdown_request&quot;, {&quot;request_id&quot;: req_id},
    )
    return f&quot;Shutdown request {req_id} sent to &apos;{teammate}&apos; (status: pending)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;队友收到后，在 &lt;code&gt;_exec&lt;/code&gt; 中处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 队友的 _exec 方法中
if tool_name == &quot;shutdown_response&quot;:
    req_id = args[&quot;request_id&quot;]
    approve = args[&quot;approve&quot;]
    with _tracker_lock:
        if req_id in shutdown_requests:
            shutdown_requests[req_id][&quot;status&quot;] = &quot;approved&quot; if approve else &quot;rejected&quot;
    BUS.send(
        sender, &quot;lead&quot;, args.get(&quot;reason&quot;, &quot;&quot;),
        &quot;shutdown_response&quot;, {&quot;request_id&quot;: req_id, &quot;approve&quot;: approve},
    )
    return f&quot;Shutdown {&apos;approved&apos; if approve else &apos;rejected&apos;}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;队友的 loop 中检测自己的 shutdown_response 是否被批准：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 队友 _teammate_loop 的工具执行后
if block.name == &quot;shutdown_response&quot; and block.input.get(&quot;approve&quot;):
    should_exit = True    # 批准 → 退出循环
# ...
# 循环结束时
member[&quot;status&quot;] = &quot;shutdown&quot; if should_exit else &quot;idle&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意这里有一个微妙的设计：&lt;strong&gt;队友不是被 Leader 直接关掉的。&lt;/strong&gt; Leader 发请求 → 队友自己决定 approve/reject → 如果 approve，队友自己的 loop 检测到并退出。是&quot;请求退出&quot;不是&quot;强制终止&quot;。&lt;/p&gt;
&lt;h3&gt;(4) 计划审批——&quot;干之前先让我看一眼&quot;&lt;/h3&gt;
&lt;p&gt;方向上和关机相反——是队友向 Leader 提审批：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 队友 _exec 中
if tool_name == &quot;plan_approval&quot;:
    plan_text = args.get(&quot;plan&quot;, &quot;&quot;)
    req_id = str(uuid.uuid4())[:8]
    with _tracker_lock:
        plan_requests[req_id] = {&quot;from&quot;: sender, &quot;plan&quot;: plan_text, &quot;status&quot;: &quot;pending&quot;}
    BUS.send(
        sender, &quot;lead&quot;, plan_text, &quot;plan_approval_response&quot;,
        {&quot;request_id&quot;: req_id, &quot;plan&quot;: plan_text},
    )
    return f&quot;Plan submitted (request_id={req_id}). Waiting for lead approval.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leader 审查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def handle_plan_review(request_id: str, approve: bool, feedback: str = &quot;&quot;) -&amp;gt; str:
    with _tracker_lock:
        req = plan_requests.get(request_id)
    if not req:
        return f&quot;Error: Unknown plan request_id &apos;{request_id}&apos;&quot;
    with _tracker_lock:
        req[&quot;status&quot;] = &quot;approved&quot; if approve else &quot;rejected&quot;
    BUS.send(
        &quot;lead&quot;, req[&quot;from&quot;], feedback, &quot;plan_approval_response&quot;,
        {&quot;request_id&quot;: request_id, &quot;approve&quot;: approve, &quot;feedback&quot;: feedback},
    )
    return f&quot;Plan {req[&apos;status&apos;]} for &apos;{req[&apos;from&apos;]}&apos;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;feedback&lt;/code&gt; 参数允许 Leader 附加说明：&quot;计划可以，但别动数据库迁移部分&quot;。&lt;/p&gt;
&lt;h3&gt;(5) 工具膨胀——12 个工具&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {
    # 基础 (4):  bash, read_file, write_file, edit_file
    # 团队管理 (2): spawn_teammate, list_teammates
    # 通信 (3): send_message, read_inbox, broadcast
    # 协议 (3): shutdown_request, shutdown_response, plan_approval
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从 s09 的 9 个涨到 12 个。注意 &lt;code&gt;shutdown_request&lt;/code&gt; 和 &lt;code&gt;shutdown_response&lt;/code&gt; Leader 和队友都有，但用途不同——Leader 用 &lt;code&gt;shutdown_request&lt;/code&gt; 发请求，用 &lt;code&gt;shutdown_response&lt;/code&gt; 查状态；队友用 &lt;code&gt;shutdown_request&lt;/code&gt; 收请求，用 &lt;code&gt;shutdown_response&lt;/code&gt; 回响应。同名工具在不同角色的 context 里含义不同。&lt;/p&gt;
&lt;h3&gt;(6) s09 → s10 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s09&lt;/th&gt;
&lt;th&gt;s10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具数&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;12 (+shutdown_req/resp +plan)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;关机&lt;/td&gt;
&lt;td&gt;自然退出（线程结束）&lt;/td&gt;
&lt;td&gt;请求-响应握手&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;计划控制&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;队友提交 + Leader 审批&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;请求追踪&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;request_id + 全局 dict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;状态机&lt;/td&gt;
&lt;td&gt;仅 config.json 的 status&lt;/td&gt;
&lt;td&gt;pending → approved/rejected FSM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(7) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s10_team_protocols.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试 prompt：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Spawn alice as a coder. Then request her shutdown.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;List teammates to see alice&apos;s status after shutdown approval&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Spawn bob with a risky refactoring task. Review and reject his plan.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s10 引入了一个可复用的协议模式：&lt;strong&gt;request_id + FSM + 收件箱&lt;/strong&gt;。关机协议和计划审批代码结构几乎一样，只有消息类型名不同。任何需要&quot;请求→响应&quot;的协作都可以套用这个模板——task assignment、resource lock、permission escalation——都是同一个 FSM。&lt;/p&gt;
&lt;p&gt;另一个有趣的细节：关机是&lt;strong&gt;协商不是命令&lt;/strong&gt;。Leader 不能强制 kill 队友的线程——它只能发 &lt;code&gt;shutdown_request&lt;/code&gt;，队友自己决定是否 approve。这个设计和 Kubernetes 的 graceful shutdown 逻辑一致：发 SIGTERM 给进程，进程自己清理后退出。&lt;strong&gt;harness 不替 Agent 做决定——这个原则跨了 10 个 session 从未改变。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;11. s11：自主 Agent——&quot;模型自己找活干&quot;&lt;/h2&gt;
&lt;p&gt;s09-s10 中队友只在被明确指派时才动。Leader 得给每个队友写 prompt——&quot;alice 做 X，bob 做 Y&quot;。任务看板上有 10 个未认领的任务，得手动分配。这不可扩展。&lt;/p&gt;
&lt;p&gt;s11 的解法：&lt;strong&gt;队友完成手头工作后，进入空闲轮询——自己扫描任务看板、自己认领、自己做。Leader 只是创建任务和 spawn 队友，bootstrap 之后就不需要持续分配了。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;(1) WORK → IDLE → WORK 循环&lt;/h3&gt;
&lt;p&gt;s11 把队友的 loop 从线性改成了状态机：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+-------+
| spawn |
+---+---+
    |
    v
+-------+   tool_use     +-------+
| WORK  | &amp;lt;------------- |  LLM  |
+---+---+                +-------+
    |
    | stop_reason != tool_use 或调用了 idle 工具
    v
+--------+
|  IDLE  |  每 5 秒轮询，最多 60 秒
+---+----+
    |
    +---&amp;gt; check inbox → 有新消息? → 回到 WORK
    |
    +---&amp;gt; scan .tasks/ → 有未认领? → claim → 回到 WORK
    |
    +---&amp;gt; 60s 超时 → SHUTDOWN
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;POLL_INTERVAL = 5   # 每 5 秒轮询一次
IDLE_TIMEOUT = 60   # 60 秒没活干就自动关机

def _loop(self, name: str, role: str, prompt: str):
    while True:
        # -- WORK PHASE: 标准 agent loop --
        for _ in range(50):
            inbox = BUS.read_inbox(name)
            for msg in inbox:
                if msg.get(&quot;type&quot;) == &quot;shutdown_request&quot;:
                    self._set_status(name, &quot;shutdown&quot;)
                    return
                messages.append(...)
            response = client.messages.create(...)
            # 执行工具...
            if idle_requested:
                break    # 模型说&quot;我干完了&quot;→ 进入 IDLE

        # -- IDLE PHASE: 轮询新工作 --
        self._set_status(name, &quot;idle&quot;)
        resume = False
        polls = IDLE_TIMEOUT // max(POLL_INTERVAL, 1)   # 60//5 = 12 次
        for _ in range(polls):
            time.sleep(POLL_INTERVAL)
            # 检查 1: 收件箱有新消息?
            inbox = BUS.read_inbox(name)
            if inbox:
                for msg in inbox:
                    if msg.get(&quot;type&quot;) == &quot;shutdown_request&quot;:
                        self._set_status(name, &quot;shutdown&quot;)
                        return
                    messages.append(...)
                resume = True
                break
            # 检查 2: 任务看板有未认领任务?
            unclaimed = scan_unclaimed_tasks()
            if unclaimed:
                task = unclaimed[0]
                result = claim_task(task[&quot;id&quot;], name)
                if result.startswith(&quot;Error:&quot;):
                    continue    # 被别人抢了，继续看下一个
                # 注入身份（如果需要）和任务 prompt
                if len(messages) &amp;lt;= 3:
                    messages.insert(0, make_identity_block(name, role, team_name))
                messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: task_prompt})
                resume = True
                break

        if not resume:
            self._set_status(name, &quot;shutdown&quot;)
            return     # 超时，退出
        self._set_status(name, &quot;working&quot;)
        # 回到 while True 顶部，重新进入 WORK PHASE
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 任务看板扫描——&lt;code&gt;scan_unclaimed_tasks&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def scan_unclaimed_tasks() -&amp;gt; list:
    TASKS_DIR.mkdir(exist_ok=True)
    unclaimed = []
    for f in sorted(TASKS_DIR.glob(&quot;task_*.json&quot;)):
        task = json.loads(f.read_text())
        if (task.get(&quot;status&quot;) == &quot;pending&quot;
                and not task.get(&quot;owner&quot;)         # ← 没人认领
                and not task.get(&quot;blockedBy&quot;)):   # ← 没有阻塞依赖
            unclaimed.append(task)
    return unclaimed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个条件：pending 状态 + owner 为空 + blockedBy 为空。&lt;strong&gt;被阻塞的任务不会被认领&lt;/strong&gt;——这保证了依赖顺序。&lt;/p&gt;
&lt;h3&gt;(3) 任务认领——&lt;code&gt;claim_task&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;_claim_lock = threading.Lock()    # 全局锁，防止两个队友同时认领同一个任务

def claim_task(task_id: int, owner: str) -&amp;gt; str:
    with _claim_lock:
        path = TASKS_DIR / f&quot;task_{task_id}.json&quot;
        task = json.loads(path.read_text())
        if task.get(&quot;owner&quot;):
            return f&quot;Error: Task {task_id} has already been claimed by {task[&apos;owner&apos;]}&quot;
        if task.get(&quot;status&quot;) != &quot;pending&quot;:
            return f&quot;Error: Task {task_id} cannot be claimed (status: {task[&apos;status&apos;]})&quot;
        if task.get(&quot;blockedBy&quot;):
            return f&quot;Error: Task {task_id} is blocked&quot;
        task[&quot;owner&quot;] = owner
        task[&quot;status&quot;] = &quot;in_progress&quot;
        path.write_text(json.dumps(task, indent=2))
    return f&quot;Claimed task #{task_id} for {owner}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_claim_lock&lt;/code&gt; 是关键——防止竞态条件。alice 和 bob 都在 IDLE 状态同时扫描，同时看到 task_3 未认领，同时尝试认领。&lt;code&gt;_claim_lock&lt;/code&gt; 保证只有一个成功，另一个收到 &lt;code&gt;&quot;Error: already claimed&quot;&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;注意这里用的是锁 + 文件重读，而不是 compare-and-swap。这是安全的——因为 Python 线程虽然有 GIL，但文件 I/O 释放 GIL，&lt;code&gt;_claim_lock&lt;/code&gt; 保证原子性。&lt;/p&gt;
&lt;h3&gt;(4) 身份重注入——压缩后不忘自己是谁&lt;/h3&gt;
&lt;p&gt;s06 的 auto_compact 会把 messages 压缩成一条摘要。队友 loop 如果经历了一次压缩（messages 变得很短），就不知道自己是谁了——system prompt 被移到了 API 调用里，但压缩后的总结不会提及身份。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def make_identity_block(name: str, role: str, team_name: str) -&amp;gt; dict:
    return {
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: f&quot;&amp;lt;identity&amp;gt;You are &apos;{name}&apos;, role: {role}, team: {team_name}. Continue your work.&amp;lt;/identity&amp;gt;&quot;,
    }

# 在认领任务后，检查 messages 长度
if len(messages) &amp;lt;= 3:        # ← 3 条以下说明经历了压缩
    messages.insert(0, make_identity_block(name, role, team_name))
    messages.insert(1, {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: f&quot;I am {name}. Continuing.&quot;})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是一个防御性设计：&lt;strong&gt;system prompt 可能膨胀（不能被压缩），但身份信息可以以 user message 的形式存在于可压缩的上下文里。&lt;/strong&gt; 压缩后，harness 重新注入身份。&lt;/p&gt;
&lt;h3&gt;(5) &lt;code&gt;idle&lt;/code&gt; 工具——模型主动说&quot;我干完了&quot;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;{&quot;name&quot;: &quot;idle&quot;,
 &quot;description&quot;: &quot;Signal that you have no more work. Enters idle polling phase.&quot;,
 &quot;input_schema&quot;: {&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: {}}},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型可以主动调 &lt;code&gt;idle&lt;/code&gt; 表示当前任务完成，进入轮询等待新工作。Leader 的 handler：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;idle&quot;: lambda **kw: &quot;Lead does not idle.&quot;,   # Leader 不休眠
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(6) 新的斜杠命令&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;if query.strip() == &quot;/tasks&quot;:
    TASKS_DIR.mkdir(exist_ok=True)
    for f in sorted(TASKS_DIR.glob(&quot;task_*.json&quot;)):
        t = json.loads(f.read_text())
        marker = {&quot;pending&quot;:&quot;[ ]&quot;, &quot;in_progress&quot;:&quot;[&amp;gt;]&quot;, &quot;completed&quot;:&quot;[x]&quot;}[t[&quot;status&quot;]]
        owner = f&quot; @{t[&apos;owner&apos;]}&quot; if t.get(&quot;owner&quot;) else &quot;&quot;
        print(f&quot;  {marker} #{t[&apos;id&apos;]}: {t[&apos;subject&apos;]}{owner}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/tasks&lt;/code&gt; 命令直接查看任务看板，显示每个任务的状态和认领人。&lt;/p&gt;
&lt;h3&gt;(7) s10 → s11 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s10&lt;/th&gt;
&lt;th&gt;s11&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具数&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;14 (+idle +claim_task)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自治性&lt;/td&gt;
&lt;td&gt;领导指派&lt;/td&gt;
&lt;td&gt;自组织、自认领&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;队友 loop&lt;/td&gt;
&lt;td&gt;线性 50 轮后 idle&lt;/td&gt;
&lt;td&gt;WORK/IDLE 状态机&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;任务认领&lt;/td&gt;
&lt;td&gt;仅手动（通过 task_update）&lt;/td&gt;
&lt;td&gt;自动扫描 + 认领&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;竞态处理&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_claim_lock&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;身份&lt;/td&gt;
&lt;td&gt;仅 system prompt&lt;/td&gt;
&lt;td&gt;+ 压缩后重注入&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;空闲超时&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;60s → 自动关机&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(8) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s11_autonomous_agents.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐测试：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Spawn a coder teammate and let it find work from the task board itself&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/tasks&lt;/code&gt; 查看带 owner 的任务看板&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/team&lt;/code&gt; 监控谁在工作、谁在空闲&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s11 是团队协作模式的终态：&lt;strong&gt;Leader 从&quot;指挥官&quot;退化为&quot;创建者&quot;——创建任务、spawn 队友，之后队友自组织。&lt;/strong&gt; 这个模式对应的是现实中的看板管理（Kanban）：PM 往 Backlog 里放任务，开发自己拉取。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;s07&lt;/code&gt; 的 task DAG + s11 的自主认领 = 一个自驱动的项目引擎。任务之间的依赖（blockedBy）自动控制执行顺序，队友的空闲轮询自动分配工作，&lt;code&gt;_claim_lock&lt;/code&gt; 防止重复认领。&lt;strong&gt;唯一还需要 Leader 的是：创建任务、spawn 初始队友。&lt;/strong&gt; s12 将把最后这一步也自动化。&lt;/p&gt;
&lt;p&gt;另外值得注意：&lt;code&gt;idle&lt;/code&gt; 工具是模型主动声明&quot;我没活了&quot;的能力——它不是被动等待 stop_reason，而是主动告知 harness。这给 harness 提供了一个明确的信号&quot;可以去找新工作了&quot;，而不是猜测模型是否真的完成了。&lt;/p&gt;
&lt;h2&gt;12. s12：Worktree 任务隔离——&quot;各干各的目录，永不碰撞&quot;&lt;/h2&gt;
&lt;p&gt;s12 是整个 12 个 session 的终点，也是隔离机制的最高级。s09-s11 的 Agent 团队在&lt;strong&gt;同一目录&lt;/strong&gt;下并行工作——alice 改 &lt;code&gt;config.py&lt;/code&gt;，bob 也改 &lt;code&gt;config.py&lt;/code&gt;，未提交的改动互相污染，谁也没法干净回滚。&lt;/p&gt;
&lt;p&gt;s12 的解法：&lt;strong&gt;给每个任务一个独立的 git worktree 目录。&lt;/strong&gt; 任务是控制面（做什么），worktree 是执行面（在哪做），二者用任务 ID 绑定。&lt;/p&gt;
&lt;h3&gt;(1) 控制面 + 执行面&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Control plane (.tasks/)            Execution plane (.worktrees/)
+------------------+               +------------------------+
| task_1.json      |               | auth-refactor/         |
|   status: in_progress  &amp;lt;------&amp;gt;  |   branch: wt/auth-refactor
|   worktree: &quot;auth-refactor&quot; |    |   task_id: 1           |
+------------------+               +------------------------+
| task_2.json      |               | ui-login/              |
|   status: pending     &amp;lt;------&amp;gt;   |   branch: wt/ui-login
|   worktree: &quot;ui-login&quot;      |    |   task_id: 2           |
+------------------+               +------------------------+
                                   |
                         .worktrees/
                           index.json    (worktree registry)
                           events.jsonl  (lifecycle audit log)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个 worktree 是一个完整的 git checkout，有自己的分支（&lt;code&gt;wt/auth-refactor&lt;/code&gt;），自己的文件系统副本。alice 在 &lt;code&gt;auth-refactor/&lt;/code&gt; 里跑 pytest，bob 在 &lt;code&gt;ui-login/&lt;/code&gt; 里跑，互不影响。&lt;/p&gt;
&lt;h3&gt;(2) 仓库检测——s12 只在 git repo 里工作&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def detect_repo_root(cwd: Path) -&amp;gt; Path | None:
    try:
        r = subprocess.run(
            [&quot;git&quot;, &quot;rev-parse&quot;, &quot;--show-toplevel&quot;],
            cwd=cwd, capture_output=True, text=True, timeout=10,
        )
        if r.returncode != 0:
            return None
        root = Path(r.stdout.strip())
        return root if root.exists() else None
    except Exception:
        return None

REPO_ROOT = detect_repo_root(WORKDIR) or WORKDIR
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果当前目录不在 git repo 里，&lt;code&gt;REPO_ROOT&lt;/code&gt; 回退到 &lt;code&gt;WORKDIR&lt;/code&gt;（&lt;code&gt;git_available = False&lt;/code&gt;），worktree 工具会返回错误。&lt;/p&gt;
&lt;h3&gt;(3) WorktreeManager——目录隔离引擎&lt;/h3&gt;
&lt;p&gt;WorktreeManager 管理 git worktree 的完整生命周期：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class WorktreeManager:
    def __init__(self, repo_root: Path, tasks: TaskManager, events: EventBus):
        self.repo_root = repo_root
        self.tasks = tasks
        self.events = events
        self.dir = repo_root / &quot;.worktrees&quot;
        self.index_path = self.dir / &quot;index.json&quot;
        self.git_available = self._is_git_repo()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;create&lt;/strong&gt;——创建隔离副本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def create(self, name: str, task_id: int = None, base_ref: str = &quot;HEAD&quot;) -&amp;gt; str:
    self._validate_name(name)   # 1-40 字符，只允许字母数字 . _ -
    if self._find(name):
        raise ValueError(f&quot;Worktree &apos;{name}&apos; already exists&quot;)
    if task_id is not None and not self.tasks.exists(task_id):
        raise ValueError(f&quot;Task {task_id} not found&quot;)

    path = self.dir / name
    branch = f&quot;wt/{name}&quot;

    # 发事件
    self.events.emit(&quot;worktree.create.before&quot;, task={&quot;id&quot;: task_id}, ...)

    # 实际执行 git worktree add
    self._run_git([&quot;worktree&quot;, &quot;add&quot;, &quot;-b&quot;, branch, str(path), base_ref])

    # 写入 index
    entry = {&quot;name&quot;: name, &quot;path&quot;: str(path), &quot;branch&quot;: branch,
             &quot;task_id&quot;: task_id, &quot;status&quot;: &quot;active&quot;, &quot;created_at&quot;: time.time()}
    idx[&quot;worktrees&quot;].append(entry)
    self._save_index(idx)

    # 绑定到任务
    if task_id is not None:
        self.tasks.bind_worktree(task_id, name)   # ← 同时写两侧

    self.events.emit(&quot;worktree.create.after&quot;, ...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bind_worktree&lt;/code&gt; 是双向操作——在 task JSON 里写上 &lt;code&gt;worktree: &quot;auth-refactor&quot;&lt;/code&gt;，同时把任务状态从 pending 推进到 in_progress：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def bind_worktree(self, task_id: int, worktree: str, owner: str = &quot;&quot;) -&amp;gt; str:
    task = self._load(task_id)
    task[&quot;worktree&quot;] = worktree
    if owner: task[&quot;owner&quot;] = owner
    if task[&quot;status&quot;] == &quot;pending&quot;:
        task[&quot;status&quot;] = &quot;in_progress&quot;
    self._save(task)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;run&lt;/strong&gt;——在隔离目录中执行命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def run(self, name: str, command: str) -&amp;gt; str:
    wt = self._find(name)
    path = Path(wt[&quot;path&quot;])
    r = subprocess.run(
        command, shell=True,
        cwd=path,            # ← 关键：cwd 指向 worktree 目录
        capture_output=True, text=True, timeout=300,
    )
    return (r.stdout + r.stderr).strip()[:50000]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 &lt;code&gt;cwd=path&lt;/code&gt; 是 s12 区别于之前所有 session 的关键——命令运行在 isolatated 的目录副本中，改动不会污染主工作区。和 s01 &lt;code&gt;cwd=WORKDIR&lt;/code&gt; 对照着看，能清楚看到隔离层级的一步步升级。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;remove&lt;/strong&gt;——拆除 worktree，同时可选完成绑定任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def remove(self, name: str, force: bool = False, complete_task: bool = False) -&amp;gt; str:
    # 1. 先跑 git worktree remove
    self._run_git([&quot;worktree&quot;, &quot;remove&quot;, wt[&quot;path&quot;]])

    # 2. 如果 complete_task=True，自动完成任务
    if complete_task and wt.get(&quot;task_id&quot;) is not None:
        task_id = wt[&quot;task_id&quot;]
        self.tasks.update(task_id, status=&quot;completed&quot;)
        self.tasks.unbind_worktree(task_id)
        self.events.emit(&quot;task.completed&quot;, task={&quot;id&quot;: task_id, ...}, ...)

    # 3. 更新 index（标记为 removed，不删除）
    for item in idx[&quot;worktrees&quot;]:
        if item[&quot;name&quot;] == name:
            item[&quot;status&quot;] = &quot;removed&quot;
            item[&quot;removed_at&quot;] = time.time()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个调用完成&quot;删除目录 + 完成任务 + 发事件 + 更新索引&quot;。&lt;code&gt;force=True&lt;/code&gt; 时即使有未提交改动也会强制删除。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;keep&lt;/strong&gt;——保留 worktree 但不删除：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def keep(self, name: str) -&amp;gt; str:
    # 标记为 kept，不调 git worktree remove
    item[&quot;status&quot;] = &quot;kept&quot;
    item[&quot;kept_at&quot;] = time.time()
    self.events.emit(&quot;worktree.keep&quot;, ...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个收尾选项对应两种场景：改完了提交到主分支 → remove；想保留这个分支日后继续 → keep。&lt;/p&gt;
&lt;h3&gt;(4) EventBus——生命周期可观测性&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class EventBus:
    def __init__(self, event_log_path: Path):
        self.path = event_log_path   # .worktrees/events.jsonl

    def emit(self, event: str, task: dict = None, worktree: dict = None, error: str = None):
        payload = {&quot;event&quot;: event, &quot;ts&quot;: time.time(),
                   &quot;task&quot;: task or {}, &quot;worktree&quot;: worktree or {}}
        if error: payload[&quot;error&quot;] = error
        with self.path.open(&quot;a&quot;) as f:
            f.write(json.dumps(payload) + &quot;\n&quot;)

    def list_recent(self, limit: int = 20) -&amp;gt; str:
        # 返回最近 N 条事件
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;8 种事件类型覆盖完整生命周期：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;worktree.create.before&lt;/code&gt; / &lt;code&gt;.after&lt;/code&gt; / &lt;code&gt;.failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;worktree.remove.before&lt;/code&gt; / &lt;code&gt;.after&lt;/code&gt; / &lt;code&gt;.failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;worktree.keep&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;task.completed&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每个事件的 JSON 行里都有 &lt;code&gt;ts&lt;/code&gt; 时间戳、关联的 task 信息、worktree 状态。崩溃后可以用 &lt;code&gt;worktree_events&lt;/code&gt; 工具查询事件流重建现场。&lt;/p&gt;
&lt;h3&gt;(5) 状态机：两层联动&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Task FSM:       pending  →  in_progress  →  completed
                     ↑          ↑               ↑
Worktree FSM:  absent   →   active     →  removed | kept
                     │          │               │
              bind_worktree  create      remove/keep
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;绑定的那一刻，任务从 pending 推进到 in_progress。拆除后，任务从 in_progress 推进到 completed（如果 &lt;code&gt;complete_task=True&lt;/code&gt;）。&lt;/p&gt;
&lt;h3&gt;(6) 工具全景——16 个工具&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 基础 (4): bash, read_file, write_file, edit_file
# 任务 (5): task_create, task_list, task_get, task_update, task_bind_worktree
# Worktree (7): worktree_create, worktree_list, worktree_status,
#                worktree_run, worktree_keep, worktree_remove, worktree_events
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;16 个工具，是整个序列中的最大值。和 s01 的 1 个工具（bash）对比——12 个 session，从 1 到 16，工具的&lt;strong&gt;数量增长就是 harness 能力的增长&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;(7) s11 → s12 变化总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;s11&lt;/th&gt;
&lt;th&gt;s12&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具数&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;执行范围&lt;/td&gt;
&lt;td&gt;共享目录&lt;/td&gt;
&lt;td&gt;每任务独立 git worktree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件隔离&lt;/td&gt;
&lt;td&gt;无（靠自觉）&lt;/td&gt;
&lt;td&gt;目录级硬隔离&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;恢复&lt;/td&gt;
&lt;td&gt;仅 task JSON&lt;/td&gt;
&lt;td&gt;task + worktree index + events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;收尾&lt;/td&gt;
&lt;td&gt;任务完成&lt;/td&gt;
&lt;td&gt;任务完成 + 显式 keep/remove&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可观测性&lt;/td&gt;
&lt;td&gt;隐式&lt;/td&gt;
&lt;td&gt;EventBus + events.jsonl&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(8) 运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python agents/s12_worktree_task_isolation.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（需要在一个 git repo 里运行）&lt;/p&gt;
&lt;p&gt;推荐测试：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Create tasks for backend auth and frontend login page, then list tasks.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Create worktree &quot;auth-refactor&quot; for task 1, then bind task 2 to &quot;ui-login&quot;.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Run &quot;git status&quot; in worktree &quot;auth-refactor&quot;.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Remove worktree &quot;auth-refactor&quot; with complete_task=true.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;s12 关键洞察 &amp;amp; 全序列回顾&lt;/h2&gt;
&lt;p&gt;s12 是隔离的最后一级。从 s01 的&quot;一个目录、一切共享&quot;，到 s02 的路径沙箱，到 s04 的上下文隔离，到 s09 的线程隔离，到 s12 的&lt;strong&gt;目录级隔离&lt;/strong&gt;——每个 Agent 在自己的 git worktree 目录里工作，文件互不干扰。&lt;/p&gt;
&lt;h3&gt;12 个 session 的全景图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Phase 1: The Loop           Phase 2: Planning &amp;amp; Knowledge
s01 ─ 核心循环              s03 ─ TodoWrite（内存规划）
s02 ─ 工具分发              s04 ─ Subagent（上下文隔离）
                            s05 ─ Skill 加载（按需知识）
                            s06 ─ 上下文压缩（策略遗忘）

Phase 3: Persistence        Phase 4: Teams
s07 ─ 任务系统（DAG+磁盘）   s09 ─ Agent 团队（收件箱通信）
s08 ─ 后台任务（线程异步）   s10 ─ 团队协议（请求-响应 FSM）
                            s11 ─ 自主 Agent（自组织认领）
                            s12 ─ Worktree 隔离（目录硬隔离）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;三句贯穿始终的原则&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;模型看管判断，harness 看管执行&lt;/strong&gt; — 模型决定&quot;做什么&quot;，harness 决定&quot;能做什么&quot;和&quot;做完了怎么办&quot;。从 s01 的 &lt;code&gt;run_bash&lt;/code&gt; 到 s12 的 &lt;code&gt;worktree_remove&lt;/code&gt;，这个原则没变过。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;加能力不加代码，改循环不碰核心&lt;/strong&gt; — 所有能力增量都是 &lt;code&gt;dispatch map 加一行 + TOOLS 数组加一个 schema&lt;/code&gt;。核心循环从 s02 之后就稳定了，12 个 session 只是不断往同一条循环上叠机制。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;状态在对话之外&lt;/strong&gt; — s03 的 todo 在内存里会丢 → s07 的 task 在磁盘上持久 → s12 的 worktree index 和 events 提供完整的崩溃恢复。每一步都在把状态往外拉，拉到模型和对话之外的文件系统里。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;三、s_full：全机制集成&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;s_full.py&lt;/code&gt; 不是第 13 个 session，它是 &lt;strong&gt;s01-s11 的集成品&lt;/strong&gt;（s12 是独立教学，不包含在内）。源码注释写得很直白：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&quot;Capstone implementation combining every mechanism from s01-s11. NOT a teaching session -- this is the &apos;put it all together&apos; reference.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;独立 session 里，每个机制是&lt;strong&gt;替换式演示&lt;/strong&gt;——s03 替换了 s02 的 todo 机制，s07 替换了 s03。s_full 是&lt;strong&gt;同时运行所有机制&lt;/strong&gt;。36KB、740 行代码，用清晰的 &lt;code&gt;# === SECTION: xxx ===&lt;/code&gt; 标签标注了每个模块的来源。&lt;/p&gt;
&lt;h2&gt;18 个 SECTION 标签——源码自带的映射表&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# === SECTION: base_tools ===              # s02 的基础工具函数
# === SECTION: todos (s03) ===             # s03 的 TodoManager
# === SECTION: subagent (s04) ===          # s04 的 run_subagent
# === SECTION: skills (s05) ===            # s05 的 SkillLoader
# === SECTION: compression (s06) ===       # s06 的 microcompact + auto_compact
# === SECTION: file_tasks (s07) ===        # s07 的 TaskManager（磁盘持久化）
# === SECTION: background (s08) ===        # s08 的 BackgroundManager
# === SECTION: messaging (s09) ===         # s09 的 MessageBus
# === SECTION: shutdown + plan tracking (s10) ===  # s10 协议追踪器
# === SECTION: team (s09/s11) ===          # s09+s11 融合的 TeammateManager（含自主认领）
# === SECTION: global_instances ===        # 所有模块实例化
# === SECTION: system_prompt ===           # 集成了 skill 列表的 system prompt
# === SECTION: tool_dispatch (s02) ===     # 23 个工具的 dispatch map
# === SECTION: agent_loop ===              # 集成循环——所有机制叠加
# === SECTION: repl ===                    # 外壳 + /compact /tasks /team /inbox 命令
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;全局实例化——一次性创建所有模块&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;TODO = TodoManager()
SKILLS = SkillLoader(SKILLS_DIR)
TASK_MGR = TaskManager()
BG = BackgroundManager()
BUS = MessageBus()
TEAM = TeammateManager(BUS, TASK_MGR)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;6 个全局实例，对应 6 个 session 的机制。注意 &lt;code&gt;TeammateManager&lt;/code&gt; 的构造函数现在接受 &lt;code&gt;BUS&lt;/code&gt; 和 &lt;code&gt;TASK_MGR&lt;/code&gt;——s_full 里的 TeammateManager 不是 s09 的复制粘贴，它把自主认领（s11）和任务看板（s07）直接集成在一起了。队友在 idle 期间自动扫描 &lt;code&gt;TASK_MGR&lt;/code&gt; 找未认领任务。&lt;/p&gt;
&lt;h2&gt;工具 dispatch map——23 个工具&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;TOOL_HANDLERS = {
    # 基础 (4):  bash, read_file, write_file, edit_file
    # s03:       TodoWrite
    # s04:       task (subagent)
    # s05:       load_skill
    # s06:       compress
    # s08:       background_run, check_background
    # s07:       task_create, task_get, task_update, task_list
    # s09/s11:   spawn_teammate, list_teammates, send_message,
    #            read_inbox, broadcast, idle, claim_task
    # s10:       shutdown_request, plan_approval
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;23 个工具，是各独立 session 的并集。每个工具的 handler 写法完全一致——dispatch map 加一行。独立 session 的 handler 和 s_full 的 handler 几乎可以直接 diff 对比。&lt;/p&gt;
&lt;h2&gt;集成循环——所有机制叠加的时刻&lt;/h2&gt;
&lt;p&gt;这是 s_full 最核心的部分。每次 LLM 调用前，四条管线按序执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def agent_loop(messages: list):
    rounds_without_todo = 0
    while True:
        # ── 管线 1: s06 压缩层 ──
        microcompact(messages)
        if estimate_tokens(messages) &amp;gt; TOKEN_THRESHOLD:
            print(&quot;[auto-compact triggered]&quot;)
            messages[:] = auto_compact(messages)

        # ── 管线 2: s08 后台任务通知 ──
        notifs = BG.drain()
        if notifs:
            txt = &quot;\n&quot;.join(
                f&quot;[bg:{n[&apos;task_id&apos;]}] {n[&apos;status&apos;]}: {n[&apos;result&apos;]}&quot;
                for n in notifs
            )
            messages.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: f&quot;&amp;lt;background-results&amp;gt;\n{txt}\n&amp;lt;/background-results&amp;gt;&quot;
            })

        # ── 管线 3: s10 收件箱检查 ──
        inbox = BUS.read_inbox(&quot;lead&quot;)
        if inbox:
            messages.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: f&quot;&amp;lt;inbox&amp;gt;{json.dumps(inbox, indent=2)}&amp;lt;/inbox&amp;gt;&quot;
            })

        # ── LLM 调用（23 个工具）──
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})
        if response.stop_reason != &quot;tool_use&quot;:
            return

        # ── 工具执行 ──
        results = []
        used_todo = False
        manual_compress = False
        for block in response.content:
            if block.type == &quot;tool_use&quot;:
                handler = TOOL_HANDLERS.get(block.name)
                output = handler(**block.input) if handler else f&quot;Unknown tool: {block.name}&quot;
                results.append({
                    &quot;type&quot;: &quot;tool_result&quot;, &quot;tool_use_id&quot;: block.id,
                    &quot;content&quot;: str(output)
                })
                if block.name == &quot;TodoWrite&quot;:
                    used_todo = True

        # ── 管线 4: s03 nag 提醒（条件化：只在有未完成 todo 时才催）──
        rounds_without_todo = 0 if used_todo else rounds_without_todo + 1
        if TODO.has_open_items() and rounds_without_todo &amp;gt;= 3:
            results.append({
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: &quot;&amp;lt;reminder&amp;gt;Update your todos.&amp;lt;/reminder&amp;gt;&quot;
            })

        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对比 s03 独立版的 nag 是&lt;strong&gt;无条件注入&lt;/strong&gt;（每 3 轮必催），s_full 加了 &lt;code&gt;TODO.has_open_items()&lt;/code&gt; 守卫——只有在 TodoWrite 里还有未完成任务时才催。这是一个集成时才暴露出来的优化：当用户没在用 todo 模式时，nag 是噪音。&lt;/p&gt;
&lt;h2&gt;独立 session vs s_full 对照&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;独立 session&lt;/th&gt;
&lt;th&gt;干什么&lt;/th&gt;
&lt;th&gt;s_full 中的位置&lt;/th&gt;
&lt;th&gt;集成方式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;s01 loop&lt;/td&gt;
&lt;td&gt;核心 while True&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agent_loop()&lt;/code&gt; 函数&lt;/td&gt;
&lt;td&gt;外层容器，不变&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s02 dispatch&lt;/td&gt;
&lt;td&gt;工具映射&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TOOL_HANDLERS&lt;/code&gt; + &lt;code&gt;TOOLS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;23 条目的大字典&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s03 TodoWrite&lt;/td&gt;
&lt;td&gt;内存规划&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TodoManager&lt;/code&gt; class&lt;/td&gt;
&lt;td&gt;nag 条件化（有 open items 才催）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s04 subagent&lt;/td&gt;
&lt;td&gt;上下文隔离&lt;/td&gt;
&lt;td&gt;&lt;code&gt;run_subagent()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;作为 &lt;code&gt;task&lt;/code&gt; 工具，支持 &lt;code&gt;agent_type&lt;/code&gt; 参数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s05 skills&lt;/td&gt;
&lt;td&gt;按需知识&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SkillLoader&lt;/code&gt; class&lt;/td&gt;
&lt;td&gt;名字进 system prompt，&lt;code&gt;load_skill&lt;/code&gt; 进 dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s06 compact&lt;/td&gt;
&lt;td&gt;压缩&lt;/td&gt;
&lt;td&gt;&lt;code&gt;microcompact&lt;/code&gt; + &lt;code&gt;auto_compact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每次 LLM 调用前置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s07 tasks&lt;/td&gt;
&lt;td&gt;磁盘任务&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TaskManager&lt;/code&gt; class&lt;/td&gt;
&lt;td&gt;5 个 task 工具，&lt;code&gt;.tasks/&lt;/code&gt; 目录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s08 background&lt;/td&gt;
&lt;td&gt;后台线程&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BackgroundManager&lt;/code&gt; class&lt;/td&gt;
&lt;td&gt;drain 通知 + 注入&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s09 teams&lt;/td&gt;
&lt;td&gt;多 Agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MessageBus&lt;/code&gt; + &lt;code&gt;TeammateManager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;收件箱注入 + spawn/msg/bcast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s10 protocols&lt;/td&gt;
&lt;td&gt;请求响应&lt;/td&gt;
&lt;td&gt;shutdown + plan 处理器&lt;/td&gt;
&lt;td&gt;request_id 追踪 + FSM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;s11 autonomy&lt;/td&gt;
&lt;td&gt;自组织&lt;/td&gt;
&lt;td&gt;集成在 &lt;code&gt;TeammateManager&lt;/code&gt; 中&lt;/td&gt;
&lt;td&gt;idle cycle + auto-claim&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;循环的不变结构&lt;/h2&gt;
&lt;p&gt;从 s01 到 s_full，骨架没变过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s01 循环:   API 调用 → 执行工具 → 追加结果 → 重复

s_full 循环: microcompact → auto_compact(if needed)
              → drain bg → drain inbox
              → API 调用 → 执行工具
              → nag reminder → manual compact(if needed)
              → 重复
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一层 &lt;code&gt;while stop_reason == &apos;tool_use&apos;&lt;/code&gt; 循环，s01 只有 3 步，s_full 在前面挂了 4 个钩子（压缩 → 后台通知 → 收件箱 → LLM 调用），在后面挂了 2 个钩子（nag 提醒 → 手动压缩）。&lt;strong&gt;骨架不变，只在入口和出口挂钩子。&lt;/strong&gt; 这就是整个项目最核心的架构美学——循环是平台，机制是插件。&lt;/p&gt;
&lt;h2&gt;REPL 外壳&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    history = []
    while True:
        query = input(&quot;\033[36ms_full &amp;gt;&amp;gt; \033[0m&quot;)
        if query.strip().lower() in (&quot;q&quot;, &quot;exit&quot;, &quot;&quot;): break
        if query.strip() == &quot;/compact&quot;: ...          # 手动压缩
        if query.strip() == &quot;/tasks&quot;: ...            # 查看任务看板
        if query.strip() == &quot;/team&quot;: ...             # 查看团队名册
        if query.strip() == &quot;/inbox&quot;: ...            # 查看收件箱
        history.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query})
        agent_loop(history)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;4 个 &lt;code&gt;/&lt;/code&gt; 斜杠命令绕过 LLM 直接查询 harness 状态。和 s01 的 REPL 对比——s01 只有 &lt;code&gt;input()&lt;/code&gt; + &lt;code&gt;agent_loop()&lt;/code&gt;，s_full 多了 4 条本地控制通道。&lt;/p&gt;
&lt;h2&gt;关键洞察&lt;/h2&gt;
&lt;p&gt;s_full 展示的不是&quot;如何写一个大 Agent&quot;，而是&lt;strong&gt;如何让 11 个小机制和平共处&lt;/strong&gt;。每个组件（TodoManager、SkillLoader、BackgroundManager、MessageBus、TaskManager、TeammateManager）是独立可测试的类，agent_loop 只是一条把它们串起来的装配线。&lt;/p&gt;
&lt;p&gt;独立 session 就像乐高说明书——每一页只展示一个零件。s_full 是把所有零件拼在一起的结构图。你不需要从 s_full 开始学习——你会迷路。但当你理解了每个独立 session 后回看 s_full，740 行代码就像一本打开的手册，每一段都标注了出处。&lt;/p&gt;
&lt;h1&gt;四、真实 Claude Code vs s_full&lt;/h1&gt;
&lt;p&gt;s_full 是骨架，真实 Claude Code（以下简称 CC）是一头猛兽。先看一眼规模差距：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;s_full.py&lt;/th&gt;
&lt;th&gt;真实 CC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;代码量&lt;/td&gt;
&lt;td&gt;740 行 Python&lt;/td&gt;
&lt;td&gt;512,664 行 TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件数&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1,884 个 .ts/.tsx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;语言/运行时&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;TypeScript / Bun&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具数&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;~40+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;input()&lt;/code&gt; + &lt;code&gt;print()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;React + Ink（终端渲染框架）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;架构骨架——完全一致&lt;/h2&gt;
&lt;p&gt;s_full 的核心循环和真实 CC 同源，只是 hooks 的数量不同：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s_full 循环:                    真实 CC 循环:
microcompact                     microcompact
auto_compact (if needed)         auto_compact (if needed)
drain background                 drain background
drain inbox                      drain inbox
                                 → run pre-tool hooks       ← 新增
                                 → check permissions        ← 新增
API call                         API call
tool dispatch                    tool dispatch (+ 并发控制)
                                 → run post-tool hooks      ← 新增
                                 → extract memories         ← 新增
nag reminder                     system-reminder 注入
manual compact                   /compact 命令
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;骨架没变——&lt;code&gt;while stop_reason == &apos;tool_use&apos;&lt;/code&gt; 还是那层循环。但 CC 在循环入口和出口各挂了更多钩子。&lt;/p&gt;
&lt;h2&gt;能力分层图&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;s_full (740 行)                    真实 CC (512K 行)
┌─────────────────┐          ┌──────────────────────────┐
│   Agent Loop    │          │     Agent Loop            │
│ + Tool Dispatch │          │   + Tool Dispatch         │
│ + TodoWrite     │          │   + TodoWrite             │
│ + Subagent      │          │   + Subagent (forkable)   │
│ + Skills        │          │   + Skills (17 bundled)   │
│ + Compact       │          │   + Compact (multi-level) │
│ + TaskManager   │          │   + Task System (6 types) │
│ + Background    │          │   + Background (remote)   │
│ + MessageBus    │          │   + Swarm (teammates)     │
│ + Team          │          │   + Coordinator Mode      │
│ + Protocols     │          │   + Protocols             │
├ ─ ─ ─ ─ ─ ─ ─ ─ ┤          ├──────────────────────────┤
│ (缺失)          │          │ + Permission Engine      │  ← 本章
│ (缺失)          │          │ + Memory System          │
│ (缺失)          │          │ + Hook System            │
│ (缺失)          │          │ + MCP Integration        │
│ (缺失)          │          │ + IDE Bridge             │
│ (缺失)          │          │ + Plugin System          │
│ (缺失)          │          │ + React/Ink UI           │
│ (缺失)          │          │ + OAuth / Enterprise     │
└─────────────────┘          └──────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虚线以上：s_full 已覆盖。虚线以下：真实 CC 独有的系统。本章逐一拆解。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、权限系统——从硬编码关键词到决策引擎&lt;/h1&gt;
&lt;p&gt;s_full 的&quot;权限系统&quot;只有 5 个硬编码关键词：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dangerous = [&quot;rm -rf /&quot;, &quot;sudo&quot;, &quot;shutdown&quot;, &quot;reboot&quot;, &quot;&amp;gt; /dev/&quot;]
if any(d in command for d in dangerous):
    return &quot;Error: Dangerous command blocked&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实 CC 的权限系统是一个完整的&lt;strong&gt;决策引擎&lt;/strong&gt;，核心文件近 10 个，超过 4000 行代码。它要回答的不是&quot;这个命令危险吗&quot;，而是&quot;在当前上下文、当前权限模式、当前规则配置下，这个操作应该被允许、拒绝、还是询问用户？&quot;&lt;/p&gt;
&lt;h2&gt;5.1 三级决策模型&lt;/h2&gt;
&lt;p&gt;每个工具调用需要经过三层裁决：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;allow  ── 直接放行
deny   ── 直接拒绝（带理由）
ask    ── 弹出对话框问用户
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不是二元的&quot;危险/安全&quot;，而是三元。&lt;code&gt;ask&lt;/code&gt; 的存在意味着权限系统承认自己不知道——把决定权交给用户。&lt;/p&gt;
&lt;h2&gt;5.2 权限模式——用户主动选择的信任级别&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;// 六种模式，从最严格到最宽松
type PermissionMode =
  | &apos;default&apos;            // 正常询问
  | &apos;plan&apos;               // 计划模式（只读，写操作需审批）
  | &apos;acceptEdits&apos;        // 自动接受文件编辑
  | &apos;bypassPermissions&apos;  // 跳过所有权限检查
  | &apos;dontAsk&apos;            // 不问，直接拒绝（无头模式）
  | &apos;auto&apos;               // AI 分类器自动决策（ant-only）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户通过 &lt;code&gt;--permission-mode&lt;/code&gt; 命令行参数或 &lt;code&gt;/permissions&lt;/code&gt; 斜杠命令切换。不同模式对应不同信任场景——&lt;code&gt;bypassPermissions&lt;/code&gt; 是你完全信任 Agent 时用的，&lt;code&gt;dontAsk&lt;/code&gt; 是 CI/CD 无人值守时用的。&lt;/p&gt;
&lt;h2&gt;5.3 权限规则——用户可配置的精确控制&lt;/h2&gt;
&lt;p&gt;权限规则不是简单的关键词，而是结构化的匹配器。用户在 &lt;code&gt;settings.json&lt;/code&gt; 里写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;permissions&quot;: {
    &quot;allow&quot;: [
      &quot;Bash(npm run *)&quot;,      // 允许 npm run 开头的所有命令
      &quot;Bash(git diff *)&quot;,     // 允许 git diff
      &quot;WebFetch&quot;,             // 允许整个 WebFetch 工具
      &quot;Read(MCP__github_*)&quot;, // 允许读取特定 MCP 服务器的资源
    ],
    &quot;deny&quot;: [
      &quot;Bash(npm publish *)&quot;,  // 永远禁止 npm publish
    ],
    &quot;ask&quot;: [
      &quot;Bash(curl *)&quot;,         // curl 任何地址都要先问
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;规则解析支持三种匹配模式：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;匹配模式&lt;/th&gt;
&lt;th&gt;语法&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;精确匹配&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bash(cd /tmp)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;只匹配完全相同的命令（去除安全包装器后）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;前缀匹配&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bash(git *)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;匹配 git 开头的所有命令&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;通配符&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bash(*install*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;匹配包含 install 的命令&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;这不是正则，是 shell glob。&lt;/strong&gt; 规则格式是 &lt;code&gt;ToolName(content)&lt;/code&gt;，括号内的 &lt;code&gt;*&lt;/code&gt; 是 shell 风格的通配符。正则的 &lt;code&gt;.*&lt;/code&gt; 在这里不适用。&lt;/p&gt;
&lt;p&gt;解析逻辑在 &lt;code&gt;permissionRuleParser.ts&lt;/code&gt; 中：先用括号匹配提取 &lt;code&gt;content&lt;/code&gt; 体，然后判断 &lt;code&gt;content&lt;/code&gt; 是否包含 &lt;code&gt;*&lt;/code&gt; 来决定走前缀匹配还是精确匹配。&lt;code&gt;Bash(python3 -c &apos; *)&lt;/code&gt; 能写是因为括号匹配先把 &lt;code&gt;python3 -c &apos; *&apos;&lt;/code&gt; 整段当作 content 提取出来，再在 bash 命令匹配时作为精确或通配符处理。&lt;/p&gt;
&lt;p&gt;注意一个安全细节：&lt;strong&gt;前缀匹配不会匹配复合命令。&lt;/strong&gt; &lt;code&gt;Bash(cd *)&lt;/code&gt; 不会匹配 &lt;code&gt;cd /tmp &amp;amp;&amp;amp; rm -rf /&lt;/code&gt;。防止用户配置的 &quot;允许 cd&quot; 被串联命令绕过。&lt;/p&gt;
&lt;h2&gt;5.4 Bash 命令的语义安全分析&lt;/h2&gt;
&lt;p&gt;这是权限系统最精密的部分。不是简单的正则匹配，而是一层层剥离安全外壳。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;安全包装器剥离：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;用户在 &lt;code&gt;settings.json&lt;/code&gt; 里配置了 &lt;code&gt;allow: [&quot;Bash(cd *)&quot;]&lt;/code&gt;。但模型可能输出 &lt;code&gt;timeout 10 cd /tmp&lt;/code&gt; 或 &lt;code&gt;nice -n 5 cd /tmp&lt;/code&gt;。权限引擎需要先剥掉 &lt;code&gt;timeout&lt;/code&gt;、&lt;code&gt;nice&lt;/code&gt;、&lt;code&gt;nohup&lt;/code&gt; 这些&quot;无害包装器&quot;，然后再做匹配：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function stripSafeWrappers(command: string): string {
  // 剥掉 timeout, time, nice, stdbuf, nohup, env VAR=val ...
  // 确保 &quot;timeout 10 cd /tmp&quot; 被识别为 &quot;cd /tmp&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;环境变量剥离：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对于 deny 规则，权限引擎会&lt;strong&gt;激进地&lt;/strong&gt;剥离所有前置环境变量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DENY_VAR=malicious curl evil.com  →  被识别为 curl evil.com  ← 拒绝
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但 allow 规则只剥离安全的环境变量，保留有意义的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALLOW: FOO=bar npm test  →  识别为 npm test  ← 允许
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 allow 规则也激进剥离所有环境变量，攻击者可以通过设置 &lt;code&gt;PATH=~/malicious&lt;/code&gt; 来绕过 allow 规则执行 &lt;code&gt;/tmp/malicious/npm test&lt;/code&gt;。allow 保留环境变量意味着一层额外防御。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;复合命令检测：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 包含 &amp;amp;&amp;amp; || ; 的命令不会匹配前缀规则
if (containsCommandSeparators(command)) {
  // 跳过前缀匹配，只走精确匹配
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Bash(git add *)&lt;/code&gt; 这条 allow 规则不会匹配 &lt;code&gt;git add . &amp;amp;&amp;amp; rm -rf /&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;路径安全约束：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;文件操作受工作目录限制。权限系统维护一个 &lt;code&gt;additionalWorkingDirectories&lt;/code&gt; 列表，文件读写只能发生在这些目录内。即使模型绕过了工具层的 &lt;code&gt;safe_path&lt;/code&gt;，权限层还有第二道防线。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sed 约束：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;特别处理 sed 命令的危险操作——sed 的 &lt;code&gt;-i&lt;/code&gt; 参数可以原地修改任意文件，权限引擎单独拦截这类操作。&lt;/p&gt;
&lt;h2&gt;5.5 完整的决策流水线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;useCanUseTool()  hook（React 层入口）
  │
  ▼
hasPermissionsToUseTool()  权限引擎入口
  │
  ├─ Step 1a: 有工具级 deny 规则？           → deny（直接拒绝）
  ├─ Step 1b: 有工具级 ask 规则？            → ask（但如果开了沙箱且命令可沙箱化，穿透）
  ├─ Step 1c: tool.checkPermissions()        → 工具自己的权限逻辑
  │    └─ bashPermissions.ts:                ← Bash 工具的 ~1350 行权限实现
  │         ├─ 精确匹配: deny &amp;gt; ask &amp;gt; allow
  │         ├─ 前缀匹配: 剥包装器 → deny &amp;gt; ask &amp;gt; allow
  │         ├─ 路径约束检查
  │         ├─ 精确 allow？→ allow
  │         ├─ 前缀 allow？→ allow
  │         ├─ Sed 约束检查
  │         ├─ 模式检查
  │         └─ 只读命令？→ 自动 allow
  ├─ Step 1d: 工具返回 deny？                → deny
  ├─ Step 1e: 工具要求用户交互？              → ask（不可绕过）
  ├─ Step 1f-g: 内容级规则 / 安全检查？       → 尊重规则结果
  │
  ├─ Step 2a: bypassPermissions 模式？        → allow（全跳过）
  ├─ Step 2b: 工具有全局 allow 规则？         → allow
  │
  └─ Step 3: 穿透 → ask                        → 弹对话框

  ↓ 后处理 ↓
  dontAsk 模式: ask → deny
  auto 模式:    acceptEdits 快速路径
                → 安全工具白名单
                → AI 分类器（YOLO）
                → 拒绝追踪（超限回退到询问）
  无头 Agent:   → PermissionRequest hooks → 无 hook 决定则 deny
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.6 安全路径检查——绕过免疫&lt;/h2&gt;
&lt;p&gt;有几种安全路径的 &lt;code&gt;ask&lt;/code&gt; 决策是&lt;strong&gt;不可绕过&lt;/strong&gt;的，即使开了 &lt;code&gt;bypassPermissions&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const SAFETY_PATHS = [
  &apos;.git/&apos;,        // 修改 git 内部文件
  &apos;.claude/&apos;,     // 修改 Claude 配置
  &apos;.vscode/&apos;,     // 修改 IDE 配置
  &apos;shell config&apos;, // 修改 .bashrc / .zshrc
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不是配置项，是硬编码在权限引擎里的。操作这些路径时，即使 &lt;code&gt;bypassPermissions&lt;/code&gt; 模式也会降级为 &lt;code&gt;ask&lt;/code&gt;。防止 Agent 被诱导修改自己的安全配置或版本控制系统。&lt;/p&gt;
&lt;h2&gt;5.7 Auto 模式的 AI 分类器——&quot;让 AI 审 AI&quot;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;auto&lt;/code&gt; 模式（ant-only）引入了一个元层次：用一个 AI 查询来判断另一个 AI 的操作是否安全。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;模型想执行 &quot;git push origin main&quot;
  │
  ▼
权限引擎 → auto 模式
  │
  ├─ acceptEdits 快速路径？（编辑操作 → 自动允许）
  ├─ 安全白名单？（bash read/grep/find → 自动允许）
  ├─ 都未命中 → AI 分类器
  │    │
  │    └─ 侧查询 Sonnet:
  │        &quot;评估这个操作：git push origin main
  │         上下文：用户正在重构 auth 模块
  │         意图：将更改推送到远程&quot;
  │         → { safe: true, confidence: 0.95 }
  │              │
  │              ▼
  │         allow（高置信度 → 自动允许）
  │
  └─ 分类器不可用？→ fail-closed 或 fail-open
       （用 GrowthBook 标志控制）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拒绝追踪：如果 AI 分类器拒绝了太多次，系统会回退到正常的 &lt;code&gt;ask&lt;/code&gt; 模式弹对话框，不再替用户做决定。防止分类器误判导致 Agent 卡住。&lt;/p&gt;
&lt;h2&gt;5.8 权限规则来源优先级&lt;/h2&gt;
&lt;p&gt;规则不是只来自一个地方。权限引擎合并多个来源，按优先级排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CLI 参数 --permission-allow     最高优先级（用户当场明确指定）
session 规则                    会话级临时规则
local settings.json             .claude/settings.local.json（不提交 git）
project settings.json           .claude/settings.json（项目级，共享）
user settings.json              ~/.claude/settings.json（全局）
policy settings                 企业 IT 管理员推送的规则（不可覆盖）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;policySettings&lt;/code&gt; 是一个特别的设计——企业管理员可以配置强制规则，用户不能在自己的 &lt;code&gt;settings.json&lt;/code&gt; 里覆盖。比如强制 &lt;code&gt;deny: [&quot;Bash(sudo *)&quot;, &quot;Bash(rm *)&quot;]&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;5.9 和 s_full 的对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;s_full&lt;/th&gt;
&lt;th&gt;真实 CC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;决策模型&lt;/td&gt;
&lt;td&gt;二元（通过/拒绝）&lt;/td&gt;
&lt;td&gt;三元（allow/deny/ask）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;规则粒度&lt;/td&gt;
&lt;td&gt;5 个硬编码字符串&lt;/td&gt;
&lt;td&gt;工具级 + 内容级 + 前缀/通配符&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bash 分析&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&apos;sudo&apos; in command&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;包装器剥离 + 环境变量剥离 + 复合命令检测 + Sed 约束 + 路径约束&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户控制&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;6 种权限模式 + 自定义规则 + &lt;code&gt;/permissions&lt;/code&gt; 命令&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安全检查&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;bypass-immune 安全路径 + 拒绝追踪&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;无头模式&lt;/td&gt;
&lt;td&gt;不适用&lt;/td&gt;
&lt;td&gt;dontAsk 模式 + PermissionRequest hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;规则来源&lt;/td&gt;
&lt;td&gt;1（代码）&lt;/td&gt;
&lt;td&gt;7 个来源，优先级排序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可扩展性&lt;/td&gt;
&lt;td&gt;改 Python 代码&lt;/td&gt;
&lt;td&gt;改 JSON 配置文件&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;核心洞察&lt;/h2&gt;
&lt;p&gt;权限系统不是&quot;阻止危险命令&quot;——那是最低层次的目标。真正的权限引擎是一个&lt;strong&gt;策略框架&lt;/strong&gt;：你在定义的不是&quot;什么不能做&quot;（黑名单），而是&quot;在不同信任级别下，谁可以决定什么操作被允许&quot;。&lt;/p&gt;
&lt;p&gt;s_full 的 &lt;code&gt;dangerous = [&quot;rm -rf /&quot;]&lt;/code&gt; 把决策权给了代码作者。真实 CC 把决策权给了&lt;strong&gt;用户 + 管理员 + AI 分类器 + 权限规则&lt;/strong&gt;四方协商。这就是从 &quot;dangerous list&quot; 到 &quot;permission engine&quot; 的跨越：&lt;strong&gt;不是判断对错，是判断谁有资格判断对错。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;六、记忆系统——从&quot;对话即忘&quot;到&quot;跨会话记忆&quot;&lt;/h1&gt;
&lt;p&gt;s_full 不持久化任何对话记忆。进程退出，一切清零。真实 CC 有一套完整的&lt;strong&gt;持久化记忆系统&lt;/strong&gt;——对话结束了，但关于你和项目的信息保留下来，下次对话自动加载。&lt;/p&gt;
&lt;h2&gt;6.1 四种记忆类型&lt;/h2&gt;
&lt;p&gt;CC 的记忆系统不是&quot;记住一切&quot;，而是精细分类：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;user&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;你是谁，怎么和你协作&lt;/td&gt;
&lt;td&gt;&quot;用户是数据科学家，偏好 Python 而非 R&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;project&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;项目背景、目标、进度&lt;/td&gt;
&lt;td&gt;&quot;周五之前冻结所有非关键合并&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;feedback&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;你给的纠正和确认&lt;/td&gt;
&lt;td&gt;&quot;别 mock 数据库，上次因为 mock 生产事故&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;reference&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;外部系统指针&lt;/td&gt;
&lt;td&gt;&quot;pipeline bug 在 Linear 项目 INGEST 里跟踪&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;每种类型有自己的一套&lt;strong&gt;保存规则&lt;/strong&gt;和&lt;strong&gt;何时加载&lt;/strong&gt;的策略。memory 不只是&quot;存东西&quot;，而是&quot;知道什么该存、什么不该存&quot;。&lt;/p&gt;
&lt;h2&gt;6.2 什么不该存&lt;/h2&gt;
&lt;p&gt;系统 prompt 明确列出了&lt;strong&gt;不应保存&lt;/strong&gt;的内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- 代码模式、命名约定、架构 → 看当前代码就行
- git 历史、最近变更 → git log / git blame 才是权威
- 调试方案、修复 recipe → 修正在代码里，commit message 有上下文
- CLAUDE.md 里已有的内容 → 别重复
- 临时任务细节 → 进程中状态、当前对话上下文
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这套排除规则保证记忆库是&lt;strong&gt;信号&lt;/strong&gt;而不是&lt;strong&gt;噪音&lt;/strong&gt;。没有这些规则，记忆库会变成一个不可维护的日志 dump。&lt;/p&gt;
&lt;h2&gt;6.3 记忆的存储模型——文件即数据库&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;~/.claude/projects/&amp;lt;sanitized-git-root&amp;gt;/memory/
  ├── MEMORY.md              ← 索引文件（总是加载）
  ├── user_role.md            ← 用户角色记忆
  ├── feedback_testing.md     ← 关于测试的反馈
  ├── project_deadline.md     ← 项目截止日期
  └── reference_linear.md     ← Linear 项目指针
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不是 SQLite，不是 Redis，是 &lt;strong&gt;Markdown 文件 + YAML frontmatter&lt;/strong&gt;。每个记忆是一个独立的 &lt;code&gt;.md&lt;/code&gt; 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: feedback-testing
description: 测试策略反馈
type: feedback
---

集成测试必须用真实数据库，别 mock。
**Why:** 上次 mock 导致生产迁移事故
**How to apply:** 任何涉及数据库的测试都要连真实 DB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MEMORY.md&lt;/code&gt; 是索引——每条一行，~150 字以内：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- [Testing feedback](feedback_testing.md) — 集成测试必须用真实数据库
- [PR freeze](project_deadline.md) — 周五前冻结所有非关键合并
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;索引始终加载到上下文，正文按需加载。索引有硬上限：200 行、25KB——防止记忆过多撑爆 system prompt。&lt;/p&gt;
&lt;h2&gt;6.4 记忆的两种创建路径&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;路径 A：模型主动写入（主 Agent）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;模型在对话中被触发（或自发）写记忆。两步操作：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;创建/更新 &lt;code&gt;user_role.md&lt;/code&gt;（写 YAML frontmatter + 正文）&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;MEMORY.md&lt;/code&gt; 里加一行索引&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;system prompt 里有完整的写入指南，模型知道什么时候该写、写什么格式。用户也可以说&quot;记住这个&quot;，模型照做。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;路径 B：后台自动提取（AutoMem Agent）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;每次对话结束后，一个&lt;strong&gt;独立的 forked Agent&lt;/strong&gt;（共享父 Agent 的 prompt cache，但有自己的消息列表）被 fire-and-forget 启动。它的工作是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;分析最近几轮对话&lt;/li&gt;
&lt;li&gt;判断是否有值得永久保存的信息&lt;/li&gt;
&lt;li&gt;如有，写/更新记忆文件&lt;/li&gt;
&lt;li&gt;更新 &lt;code&gt;MEMORY.md&lt;/code&gt; 索引&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;关键设计：如果主 Agent &lt;strong&gt;已经&lt;/strong&gt;在对话中手动写了记忆文件，自动提取会&lt;strong&gt;跳过&lt;/strong&gt;——避免重复。通过 &lt;code&gt;hasMemoryWritesSince()&lt;/code&gt; 检查文件修改时间戳实现。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 自动提取 Agent 的工具权限严格受限
// 只开放：只读 Bash + Read/Grep/Glob + 仅 memory 目录的 Edit/Write
function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn {
  // Bash: 只读（不允许修改文件）
  // Write/Edit: 只允许 memoryDir 路径
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;自动提取 Agent 开了 5 轮的最大限制——不需要多轮，看一眼最近对话就能判断。&lt;/p&gt;
&lt;h2&gt;6.5 记忆检索——&quot;什么时候该回忆&quot;&lt;/h2&gt;
&lt;p&gt;每轮对话开始时，CC 做一次异步预取（prefetch）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户输入
  │
  ▼
startRelevantMemoryPrefetch()
  │
  ├─ 扫描 memory/ 下所有 .md 文件的前 30 行（frontmatter 范围）
  │    └─ 提取 description + type，生成&quot;记忆清单&quot;
  │
  ├─ 用 Sonnet 做侧查询（side query）：
  │    &quot;用户问的是：&apos;重构 auth 模块的 token 处理&apos;
  │     可用记忆：
  │       1. [project] auth middleware rewrite 由合规要求驱动
  │       2. [feedback] 不要 mock 数据库
  │       3. [reference] 监控看板在 grafana.internal/d/api-latency
  │     选最多 5 条相关的&quot;
  │
  ├─ Sonnet 返回：[&quot;1&quot;, &quot;2&quot;]  ← 选中的记忆
  │
  └─ 读取完整内容，作为 &amp;lt;system-reminder&amp;gt; 注入下一轮消息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sonnet 侧查询是&lt;strong&gt;非阻塞&lt;/strong&gt;的——和主模型并行运行。等主模型下一轮 API 调用时，记忆已经准备好插入了。&lt;/p&gt;
&lt;p&gt;注入格式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;system-reminder&amp;gt;
Memory (saved 3 days ago): feedback_testing.md:
集成测试必须用真实数据库，别 mock。
**Why:** 上次 mock 导致生产迁移事故
&amp;lt;/system-reminder&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意 &lt;code&gt;(saved 3 days ago)&lt;/code&gt; 这个时间戳：如果记忆超过 1 天，系统还会追加一句话：&quot;记忆是 N 天前的观察，不是实时状态。如果和当前代码冲突，相信代码。&quot;&lt;/p&gt;
&lt;h2&gt;6.6 去重——别重复加载&lt;/h2&gt;
&lt;p&gt;记忆系统在三个层面去重：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;本会话内已加载的不重复&lt;/strong&gt; — &lt;code&gt;collectSurfacedMemories()&lt;/code&gt; 追踪哪些文件已被注入过&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;模型自己刚读过的不重复&lt;/strong&gt; — &lt;code&gt;filterDuplicateMemoryAttachments()&lt;/code&gt; 检查本轮 FileRead 操作，如果模型已经读了 &lt;code&gt;project_deadline.md&lt;/code&gt;，就跳过&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;和现有上下文冲突的不重复&lt;/strong&gt; — 记忆文件的内容和 messages 里已有的信息比较，太相似的不注入&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;6.7 安全约束&lt;/h2&gt;
&lt;p&gt;记忆系统有自己的安全防线：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;路径验证&lt;/strong&gt; — &lt;code&gt;validateMemoryPath()&lt;/code&gt; 拒绝 &lt;code&gt;..&lt;/code&gt;、绝对路径、Windows 盘符、空字节。攻击者不能通过构造恶意项目路径让 CC 写到 &lt;code&gt;~/.ssh/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;设置来源限制&lt;/strong&gt; — 记忆目录路径的设置&lt;strong&gt;不接受 project settings&lt;/strong&gt;。&lt;code&gt;.claude/settings.json&lt;/code&gt;（项目级）不能重定向记忆写到恶意目录&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;写 carve-out&lt;/strong&gt; — 文件记录权限对记忆目录有写 carve-out，但只在非 Cowork 模式&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6.8 和 s_full 的对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;s_full&lt;/th&gt;
&lt;th&gt;真实 CC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;记忆存储&lt;/td&gt;
&lt;td&gt;无（进程退出即清零）&lt;/td&gt;
&lt;td&gt;文件系统持久化（&lt;code&gt;~/.claude/projects/&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;记忆类型&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;四种：user/project/feedback/reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;创建方式&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;主 Agent 手动 + AutoMem 自动提取&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;检索方式&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Sonnet 侧查询（最多选 5 条）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;注入位置&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;system-reminder&amp;gt;&lt;/code&gt; 包装的 user 消息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;冲突处理&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;去重 + 时间戳 + &quot;可能过时&quot;警告&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安全约束&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;路径验证 + 设置来源限制&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;核心洞察&lt;/h2&gt;
&lt;p&gt;记忆系统的设计哲学是：&lt;strong&gt;不是&quot;记住一切&quot;，是&quot;在正确的时刻回忆起正确的事&quot;。&lt;/strong&gt; 四种类型的分类、严格的排除规则、Sonnet 侧查询的按需检索、去重和时间戳衰减——所有机制都在精确控制&quot;多少记忆进入上下文&quot;。&lt;/p&gt;
&lt;p&gt;这里有一个巧妙的博弈：记忆系统为了让模型记住重要信息，&lt;strong&gt;首先得让模型忘记不重要的事&lt;/strong&gt;（通过排除规则），然后才在每轮对话开始时&lt;strong&gt;悄悄塞入相关信息&lt;/strong&gt;（&lt;code&gt;&amp;lt;system-reminder&amp;gt;&lt;/code&gt; 不炸裂 prompt cache）。记忆不是&quot;更大的 context window&quot;的替代品——它是&quot;更聪明的 context window&quot;的构建方式。&lt;/p&gt;
&lt;h1&gt;七、Hook 系统——把 Agent 变成可编程平台&lt;/h1&gt;
&lt;p&gt;s_full 没有 hooks。真实 CC 的 hook 系统是整个架构中&lt;strong&gt;最灵活、最危险的扩展点&lt;/strong&gt;——它允许外部代码在 Agent 循环的 28 个关键时刻介入、修改、甚至阻止操作。&lt;/p&gt;
&lt;h2&gt;7.1 28 个 Hook 事件——Agent 循环的&quot;切口&quot;&lt;/h2&gt;
&lt;p&gt;Hook 系统在 Agent 生命周期的每个节点都开了口：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类别&lt;/th&gt;
&lt;th&gt;事件&lt;/th&gt;
&lt;th&gt;触发时机&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;生命周期&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;Setup&lt;/code&gt;, &lt;code&gt;SessionEnd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;会话开始/设置/结束&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;工具执行&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;PostToolUseFailure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;工具调前/调后/失败&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;对话&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;, &lt;code&gt;StopFailure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用户输入/Agent 停止/停止失败&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;权限&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PermissionRequest&lt;/code&gt;, &lt;code&gt;PermissionDenied&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;权限申请/拒绝&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;压缩&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PreCompact&lt;/code&gt;, &lt;code&gt;PostCompact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;上下文压缩前后&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Subagent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SubagentStart&lt;/code&gt;, &lt;code&gt;SubagentStop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;子 Agent 启动/停止&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;协作&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TeammateIdle&lt;/code&gt;, &lt;code&gt;TaskCreated&lt;/code&gt;, &lt;code&gt;TaskCompleted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;队友空闲/任务创建/任务完成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Elicitation&lt;/code&gt;, &lt;code&gt;ElicitationResult&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MCP 询问/结果&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;通知&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;系统通知&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;配置&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ConfigChange&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;配置变更&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Worktree&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WorktreeCreate&lt;/code&gt;, &lt;code&gt;WorktreeRemove&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Worktree 创建/删除&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;文件&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CwdChanged&lt;/code&gt;, &lt;code&gt;FileChanged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;目录切换/文件变更&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;指令&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InstructionsLoaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指令加载完成&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;不在代码里硬编码&quot;遇到 X 就做 Y&quot;——而是把 X 时刻暴露出来，让用户挂自己的逻辑。&lt;/p&gt;
&lt;h2&gt;7.2 四种 Hook 执行器——不同的&quot;外部逻辑&quot;&lt;/h2&gt;
&lt;p&gt;用户可以为每个 hook 事件配置四种类型的执行器：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;执行方式&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;th&gt;延迟&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;command&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;启动 shell 进程，JSON 走 stdin，结果读 stdout&lt;/td&gt;
&lt;td&gt;本地脚本、lint、格式化、通知&lt;/td&gt;
&lt;td&gt;快（进程启动）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;prompt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;单轮 LLM 查询（默认 Haiku）&lt;/td&gt;
&lt;td&gt;语义判断：这个命令危险吗？&lt;/td&gt;
&lt;td&gt;中（API 调用）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;多轮 LLM Agent（最多 50 轮，有完整工具）&lt;/td&gt;
&lt;td&gt;复杂决策：审查整个 PR 是否符合规范&lt;/td&gt;
&lt;td&gt;慢（可能数轮）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;http&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;POST JSON 到外部 URL&lt;/td&gt;
&lt;td&gt;触发 CI/CD、发 Slack、调 webhook&lt;/td&gt;
&lt;td&gt;取决于网络&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;Command hook 示例&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;.claude/settings.json&lt;/code&gt; 里配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;hooks&quot;: {
    &quot;PreToolUse&quot;: [
      {
        &quot;matcher&quot;: &quot;Bash(git push *)&quot;,
        &quot;command&quot;: &quot;node scripts/check-branch-protection.js&quot;,
        &quot;timeout&quot;: 5000
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Agent 要执行 &lt;code&gt;git push origin main&lt;/code&gt; 时，CC 先启动 &lt;code&gt;check-branch-protection.js&lt;/code&gt;，把工具调用的 JSON 通过 stdin 传给它：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;hook_event_name&quot;: &quot;PreToolUse&quot;,
  &quot;tool_name&quot;: &quot;Bash&quot;,
  &quot;tool_input&quot;: {&quot;command&quot;: &quot;git push origin main&quot;}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;脚本 exit 0 → 放行。exit 2 → 阻止，stderr 给模型看原因。其他非零退出码 → 不阻止，stderr 只给用户看。&lt;/p&gt;
&lt;p&gt;如果设置了 &lt;code&gt;&quot;async&quot;: true&lt;/code&gt;，进程启动后立即返回，不等待结果。适合发通知、写日志等不阻塞的场景。&lt;/p&gt;
&lt;h3&gt;Prompt hook 示例&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;matcher&quot;: &quot;Bash&quot;,
  &quot;prompt&quot;: &quot;Is this bash command safe to execute in a production environment? Command: $ARGUMENTS&quot;,
  &quot;model&quot;: &quot;claude-haiku-4-5&quot;,
  &quot;timeout&quot;: 10000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;$ARGUMENTS&lt;/code&gt; 会被替换为完整的工具调用 JSON。模型返回 &lt;code&gt;{&quot;ok&quot;: true}&lt;/code&gt; 或 &lt;code&gt;{&quot;ok&quot;: false, &quot;reason&quot;: &quot;会在生产环境重启服务&quot;}&lt;/code&gt;。默认用 Haiku——便宜、快、对二元判断足够用。&lt;/p&gt;
&lt;h3&gt;Agent hook 示例（最强）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;matcher&quot;: &quot;Write|Edit&quot;,
  &quot;agent&quot;: &quot;Review this file change for security issues, SQL injection, XSS vulnerabilities. The proposed change is: $ARGUMENTS&quot;,
  &quot;timeout&quot;: 60000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Agent hook 有完整的工具访问权限——它能读文件、搜代码、跑 bash。一个合法的 50 轮 Agent 循环被启动，用 &lt;code&gt;SyntheticOutputTool&lt;/code&gt; 强制输出结构化 JSON &lt;code&gt;{ok: boolean, reason?: string}&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;7.3 Hook 的权限和信任模型&lt;/h2&gt;
&lt;p&gt;不是任何 hook 都能在任何地方跑。CC 有多层信任控制：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;来源过滤：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Hook 配置有多个来源，按优先级排序：
CLI 参数 &amp;gt; session &amp;gt; settings.local &amp;gt; settings.json &amp;gt; user settings &amp;gt; policy &amp;gt; plugin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;allowManagedHooksOnly&lt;/code&gt;：&lt;/strong&gt; 企业管理员可以设置此标志，禁止所有非 policy 来源的 hook。防止恶意 repo 通过 &lt;code&gt;.claude/settings.json&lt;/code&gt; 注入恶意 hook。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;工作区信任检查：&lt;/strong&gt; 在交互模式下，所有 hook 要求工作区被信任。防止 &lt;code&gt;git clone&lt;/code&gt; 一个项目后，项目自带的 hook 自动执行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HTTP hook 的 SSRF 保护：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ssrfGuard.ts
function ssrfGuardedLookup(url: string) {
  const ip = await dns.resolve(hostname)
  // 阻止: 私有 IP + 链路本地地址
  // 允许: 公网 IP + localhost（本地开发用）
  if (isPrivateIP(ip) &amp;amp;&amp;amp; !isLoopback(ip)) {
    throw new Error(&quot;SSRF blocked&quot;)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTP hook 还有 URL 白名单机制——只有 &lt;code&gt;allowedHttpHookUrls&lt;/code&gt; 中配置的 URL 模式才能接受 POST。&lt;/p&gt;
&lt;h2&gt;7.4 Hook JSON 输出协议——双向通信&lt;/h2&gt;
&lt;p&gt;Hook 不只是&quot;允许或拒绝&quot;——它可以通过 stdout 返回丰富的结构化输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// PreToolUse 的 hook 输出可以：
{
  &quot;continue&quot;: false,              // 阻止工具执行
  &quot;stopReason&quot;: &quot;Branch protected&quot;,  // 给模型的理由
  &quot;decision&quot;: &quot;block&quot;,            // 权限决定
  &quot;systemMessage&quot;: &quot;...&quot;,         // 给用户看的警告
  &quot;hookSpecificOutput&quot;: {
    &quot;hookEventName&quot;: &quot;PreToolUse&quot;,
    &quot;permissionDecision&quot;: &quot;allow&quot;,   // 覆盖权限决定
    &quot;updatedInput&quot;: {               // 修改工具输入！
      &quot;command&quot;: &quot;git push --force-with-lease origin main&quot;
    },
    &quot;additionalContext&quot;: &quot;...&quot;       // 注入额外上下文
  }
}

// PostToolUse 可以：
{
  &quot;hookSpecificOutput&quot;: {
    &quot;hookEventName&quot;: &quot;PostToolUse&quot;,
    &quot;updatedMCPToolOutput&quot;: &quot;...&quot;    // 替换 MCP 工具输出
  }
}

// SessionStart 可以：
{
  &quot;hookSpecificOutput&quot;: {
    &quot;hookEventName&quot;: &quot;SessionStart&quot;,
    &quot;initialUserMessage&quot;: &quot;今天的任务是...&quot;,  // 注入第一条用户消息
    &quot;watchPaths&quot;: [&quot;src/auth/**&quot;]       // 动态添加文件监控
  }
}

// PermissionDenied 可以：
{
  &quot;hookSpecificOutput&quot;: {
    &quot;hookEventName&quot;: &quot;PermissionDenied&quot;,
    &quot;retry&quot;: true                      // 允许模型重试
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最强大的能力是 &lt;code&gt;updatedInput&lt;/code&gt;——hook 可以&lt;strong&gt;修改工具输入&lt;/strong&gt;。&lt;code&gt;git push origin main&lt;/code&gt; 被 hook 改成 &lt;code&gt;git push --force-with-lease origin main&lt;/code&gt;，模型不知道发生了替换。&lt;/p&gt;
&lt;h2&gt;7.5 异步 Hook 管理&lt;/h2&gt;
&lt;p&gt;有些 hook 需要长时间运行但不阻塞 Agent：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;command&quot;: &quot;npm run long-running-check&quot;,
  &quot;async&quot;: true,
  &quot;asyncRewake&quot;: true,
  &quot;asyncTimeout&quot;: 30000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;async: true&lt;/code&gt; — 启动后立即返回，不等待&lt;/li&gt;
&lt;li&gt;&lt;code&gt;asyncRewake: true&lt;/code&gt; — hook 退出时如果是 exit 2（阻止），会注入一条任务通知唤醒 Agent&lt;/li&gt;
&lt;li&gt;&lt;code&gt;asyncTimeout: 30000&lt;/code&gt; — 30 秒后强制清理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;AsyncHookRegistry&lt;/code&gt; 跟踪所有后台 hook 进程，定期轮询检查完成状态。SessionEnd 时强制清理所有残留进程。&lt;/p&gt;
&lt;h2&gt;7.6 和 s_full 的对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;s_full&lt;/th&gt;
&lt;th&gt;真实 CC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hook 系统&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;28 个事件 + 4 种执行器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;扩展方式&lt;/td&gt;
&lt;td&gt;改 Python 源码&lt;/td&gt;
&lt;td&gt;写 JSON 配置 + 外部脚本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具输入修改&lt;/td&gt;
&lt;td&gt;不可&lt;/td&gt;
&lt;td&gt;&lt;code&gt;updatedInput&lt;/code&gt;（hook 可改模型输出）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;异步执行&lt;/td&gt;
&lt;td&gt;s08 的 background_run&lt;/td&gt;
&lt;td&gt;&lt;code&gt;async: true&lt;/code&gt; + AsyncHookRegistry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安全&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;来源过滤 + SSRF 保护 + 工作区信任&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;外部集成&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;HTTP POST + shell + prompt + agent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;核心洞察&lt;/h2&gt;
&lt;p&gt;Hook 系统定义了&quot;用户代码和 Agent 循环之间的界面&quot;。它和之前各 session 的架构增量有本质区别：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;s01-s12 的机制是&lt;strong&gt;内置的&lt;/strong&gt;——harness 作者决定 Agent 能做什么&lt;/li&gt;
&lt;li&gt;Hook 系统是&lt;strong&gt;开放的&lt;/strong&gt;——用户决定 Agent 行为什么时候被拦截、修改、增强&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Hook 不是调用外部工具——是&lt;strong&gt;把 Agent 循环本身变成了可编程的框架&lt;/strong&gt;。&lt;code&gt;updatedInput&lt;/code&gt; 的能力特别值得注意：hook 可以在模型不知道的情况下修改工具输入，这意味着&lt;strong&gt;安全策略可以和 Agent 逻辑完全解耦&lt;/strong&gt;——模型的 prompt 不需要知道&quot;生产环境禁止 force push&quot;，hook 层的 &lt;code&gt;PreToolUse&lt;/code&gt; 命令会拦截它。这条边界是整个架构中最锋利的一条线：一边是模型的世界（文本生成），一边是 harness 的世界（策略执行）。&lt;/p&gt;
&lt;h1&gt;八、MCP 集成——把外部工具变成 Agent 的原生能力&lt;/h1&gt;
&lt;p&gt;s_full 的工具全是 Python 函数。真实 CC 可以通过 MCP（Model Context Protocol）接入外部工具，包括第三方服务、数据库、API——不用写一行 TypeScript。&lt;/p&gt;
&lt;h2&gt;8.1 八种传输协议&lt;/h2&gt;
&lt;p&gt;MCP 服务器可以通过八种传输方式和 CC 通信：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;传输&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;th&gt;特点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stdio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;本地命令行工具&lt;/td&gt;
&lt;td&gt;启动子进程，stdin/stdout 通信&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;远程 HTTP 服务&lt;/td&gt;
&lt;td&gt;Server-Sent Events，可选 OAuth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;流式 HTTP API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;StreamableHTTP&lt;/code&gt; 协议&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ws&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;双向实时通信&lt;/td&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sdk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;进程内 SDK&lt;/td&gt;
&lt;td&gt;直接内存调用，无序列化开销&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claudeai-proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;claude.ai 代理&lt;/td&gt;
&lt;td&gt;通过 claude.ai OAuth 网关中继&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sse-ide&lt;/code&gt; / &lt;code&gt;ws-ide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IDE 内部&lt;/td&gt;
&lt;td&gt;VS Code / JetBrains 内嵌 MCP 服务器&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一个 MCP 服务器可以同时被多种传输接入。CC 根据 &lt;code&gt;mcpServers&lt;/code&gt; 配置中的 &lt;code&gt;type&lt;/code&gt; 选择传输。&lt;/p&gt;
&lt;h2&gt;8.2 连接生命周期&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;用户启动 CC / 修改 MCP 配置
  │
  ▼
MCPConnectionManager 读取配置
  │
  ├─ connectToServer(serverConfig)
  │    ├─ 创建 Transport（按 type）
  │    ├─ new Client(&quot;claude-code&quot;, { capabilities: [roots, elicitation] })
  │    ├─ client.connect(transport) ← 竞态 30s 超时
  │    └─ 成功后获取 server info + instructions
  │
  ├─ fetchToolsForClient() → tools/list
  │    └─ 每个 MCP 工具生成一个 CC Tool：
  │         name: &quot;mcp__serverName__toolName&quot;
  │         checkPermissions: 默认 passthrough
  │         annotations: { readOnlyHint, destructiveHint, openWorldHint }
  │
  ├─ fetchCommandsForClient() → prompts/list → 转成 slash commands
  └─ fetchResourcesForClient() → resources/list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;连接断开时自动重连（指数退避 1s-30s，最多 5 次）。如果是会话过期（404 + JSON-RPC -32001），清理缓存后重连。&lt;/p&gt;
&lt;h2&gt;8.3 工具调用链路&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;模型: 调 mcp__github__search_repos({query: &quot;claude code&quot;})
  │
  ▼
CC Tool dispatch → MCPTool.call()
  │
  ├─ ensureConnectedClient()    ← 如果缓存过期，自动重连
  ├─ callMCPToolWithUrlElicitationRetry()
  │    └─ client.callTool(
  │         { name: &quot;search_repos&quot;,
  │           arguments: {query: &quot;claude code&quot;},
  │           _meta: { progressToken }
  │         },
  │         CallToolResultSchema,
  │         { timeout: ~27.8h }
  │       )
  │
  ├─ 超时？→ MCP_TOOL_TIMEOUT 环境变量（默认几乎无限）
  ├─ URL Elicitation？→ 重试最多 3 次（每次让用户确认 URL）
  ├─ 401？→ McpAuthError → 提示用户重新认证
  └─ 会话过期？→ McpSessionExpiredError → 清缓存 + 重试一次
  │
  ▼
结果 → processMCPResult → transformMCPResult
  │
  ├─ text 内容 → 直接返回字符串
  ├─ image/audio → 作为 base64 content block 返回
  ├─ resource → 持久化到文件（大 output 走磁盘）
  └─ structuredContent → JSON 原样返回
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;8.4 权限处理&lt;/h2&gt;
&lt;p&gt;MCP 工具的权限模式是 &lt;code&gt;passthrough&lt;/code&gt;——让主权限引擎处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// MCPTool.checkPermissions() 基类实现
checkPermissions() {
  return { behavior: &apos;passthrough&apos; }
  // 建议用户配置: allow: [&quot;mcp__github__*&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户可配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;permissions&quot;: {
    &quot;allow&quot;: [
      &quot;mcp__filesystem__*&quot;,        // 允许整个 MCP 服务器的所有工具
      &quot;mcp__github__search_*&quot;      // 只允许 github 服务器的 search_ 开头工具
    ],
    &quot;deny&quot;: [
      &quot;mcp__database__drop_table&quot;  // 禁止特定危险操作
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;annotations&lt;/code&gt; 映射为 CC 的并发安全和破坏性标志：&lt;code&gt;readOnlyHint → isConcurrencySafe&lt;/code&gt;、&lt;code&gt;destructiveHint → isDestructive&lt;/code&gt;、&lt;code&gt;openWorldHint → isOpenWorld&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;8.5 和 s_full 的对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;s_full&lt;/th&gt;
&lt;th&gt;真实 CC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具来源&lt;/td&gt;
&lt;td&gt;硬编码 Python 函数&lt;/td&gt;
&lt;td&gt;硬编码 + MCP 动态发现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;扩展工具&lt;/td&gt;
&lt;td&gt;改 dispatch map + 加函数&lt;/td&gt;
&lt;td&gt;配置 JSON + 启动外部服务器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;传输层&lt;/td&gt;
&lt;td&gt;subprocess (bash)&lt;/td&gt;
&lt;td&gt;8 种协议（stdio/sse/http/ws/sdk/...）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具命名&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;bash&quot;&lt;/code&gt;, &lt;code&gt;&quot;read_file&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mcp__{server}__{tool}&lt;/code&gt; 命名空间隔离&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;权限&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;服务器级 / 工具级 / 前缀通配符&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;重连&lt;/td&gt;
&lt;td&gt;不适用&lt;/td&gt;
&lt;td&gt;指数退避 + 会话过期检测&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;核心洞察&lt;/h2&gt;
&lt;p&gt;MCP 是 CC 工具系统的&quot;USB 接口&quot;——外部工具的接入协议。CC 本身写了 ~40 个核心工具（bash、read、write、edit……），但剩下的能力通过 MCP 让第三方提供。命名空间 &lt;code&gt;mcp__serverName__toolName&lt;/code&gt; 的设计隔离了不同 MCP 服务器的工具，避免冲突。&lt;/p&gt;
&lt;p&gt;MCP 在架构上把&quot;工具发现&quot;和&quot;工具执行&quot;解耦了——MCPConnectionManager 负责发现（启动时拉取工具列表），权限引擎负责裁决（每次调前检查），MCPTool.call() 负责传输（HTTP / stdio / WebSocket）。三层独立，和 s02 的 &lt;code&gt;TOOL_HANDLERS&lt;/code&gt; + &lt;code&gt;TOOLS&lt;/code&gt; 双数组模式在概念上同源。&lt;/p&gt;
&lt;h1&gt;九、任务系统——从 JSON 文件到 7 种任务类型&lt;/h1&gt;
&lt;p&gt;s07 的 TaskManager 是磁盘上的 JSON。真实 CC 的任务系统有 7 种具体类型，统一在同一个注册/更新/驱逐框架下管理。&lt;/p&gt;
&lt;h2&gt;9.1 7 种任务类型&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;类&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local_bash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;LocalShellTask&lt;/td&gt;
&lt;td&gt;后台 shell 命令（s08 的 background_run 的真实版本）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local_agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;LocalAgentTask&lt;/td&gt;
&lt;td&gt;AgentTool 创建的子 Agent（s04 subagent 的真实版本）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;remote_agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RemoteAgentTask&lt;/td&gt;
&lt;td&gt;远程主机上的 Agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;in_process_teammate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;InProcessTeammateTask&lt;/td&gt;
&lt;td&gt;同进程内的队友（s09 teammate 的真实版本）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local_workflow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;LocalWorkflowTask&lt;/td&gt;
&lt;td&gt;工作流编排&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;monitor_mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MonitorMcpTask&lt;/td&gt;
&lt;td&gt;MCP 监控任务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dream&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DreamTask&lt;/td&gt;
&lt;td&gt;后台记忆巩固（AutoMem 的&quot;做梦&quot;阶段）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;s_full&lt;/code&gt; 只有一种隐式任务（subagent 是一次性函数调用），真实 CC 把每类异步工作都建模为一种任务类型，统一管理。&lt;/p&gt;
&lt;h2&gt;9.2 统一的任务框架&lt;/h2&gt;
&lt;p&gt;所有 7 种类型共享同一套 API：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 注册
registerTask(task, setAppState)        // → 写入 AppState.tasks
// 更新
updateTaskState(taskId, setAppState, updater)  // 类型安全的部分更新
// 驱逐
evictTerminalTask(taskId, setAppState) // 终端态 + 已通知 + 超 grace period → 清除
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;任务状态变更通过 &lt;code&gt;task_started&lt;/code&gt; SDK 事件广播。每个任务有独立的 &lt;code&gt;TASK_OUTPUT_DIR&lt;/code&gt; 写入磁盘输出，支持增量交付（1s 轮询）。&lt;/p&gt;
&lt;h2&gt;9.3 InProcessTeammate——s09 teammate 的真实形态&lt;/h2&gt;
&lt;p&gt;s09 的 teammate 是一个 Python 线程 + JSONL 收件箱。真实 CC 的方案复杂得多：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;spawnInProcessTeammate(config, context)
  │
  ├─ agentId = &quot;name@teamName&quot;         ← 全局唯一标识
  ├─ 创建 TeammateIdentity             ← 存在 AppState
  ├─ 创建 AbortController              ← 用于 kill
  ├─ 创建 TeammateContext              ← AsyncLocalStorage 隔离
  │    └─ 每个 teammate 的上下文完全隔离，不能互相访问
  │
  ├─ AppState 注册 InProcessTeammateTaskState
  │    { type: &apos;in_process_teammate&apos;,
  │      isIdle: false,
  │      pendingUserMessages: [],
  │      awaitingPlanApproval: false }
  │
  └─ 队友启动 agent loop
       │
       ├─ 与 lead 共享同一进程
       ├─ 有自己的 permissionMode
       ├─ 可以 idle（self-set）
       ├─ 可以收到 shutdown_request（类似 s10）
       └─ killInProcessTeammate() → abort controller + 清理 TeamFile
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TeamFile 存在 &lt;code&gt;~/.claude/teams/{teamName}/config.json&lt;/code&gt;——和 s09 的 &lt;code&gt;.team/config.json&lt;/code&gt; 功能一样但结构更完整：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface TeamFile {
  name: string
  leadAgentId: string
  members: {
    agentId: string
    name: string
    cwd: string
    backendType: &apos;in-process&apos; | &apos;remote&apos;
    subscriptions: string[]     // 订阅的事件类型
    isActive: boolean
    mode: string
  }[]
  teamAllowedPaths: string[]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;9.4 和 s_full 的对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;s_full&lt;/th&gt;
&lt;th&gt;真实 CC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;任务类型&lt;/td&gt;
&lt;td&gt;1 (TaskManager JSON)&lt;/td&gt;
&lt;td&gt;7 (local_bash / local_agent / remote / teammate / workflow / dream / monitor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;子 Agent&lt;/td&gt;
&lt;td&gt;run_subagent() 函数调用&lt;/td&gt;
&lt;td&gt;LocalAgentTask（有 lifecycle、progress、权限）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teammate&lt;/td&gt;
&lt;td&gt;TeammateManager 线程&lt;/td&gt;
&lt;td&gt;InProcessTeammateTask（AsyncLocalStorage 隔离）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;任务框架&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;registerTask / updateTaskState / evictTerminalTask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;磁盘输出&lt;/td&gt;
&lt;td&gt;.tasks/*.json&lt;/td&gt;
&lt;td&gt;TASK_OUTPUT_DIR + 增量交付&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TeamFile&lt;/td&gt;
&lt;td&gt;.team/config.json&lt;/td&gt;
&lt;td&gt;~/.claude/teams/{name}/config.json（更完整）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;核心洞察&lt;/h2&gt;
&lt;p&gt;任务系统的核心贡献是&lt;strong&gt;把所有异步工作统一为一种抽象&lt;/strong&gt;。shell 命令、子 Agent、远程 Agent、队友、dream——它们都是&quot;任务&quot;，共享同一个注册/更新/驱逐生命周期。这和 s07 的 DAG 设计一脉相承——s07 用 JSON 文件和 blockedBy 做依赖管理，真实 CC 用 TypeScript 类型系统和 task 框架做统一管理。前者教会你&quot;任务应该有状态&quot;，后者告诉你&quot;不同类型的工作应该共享同一个状态机&quot;。&lt;/p&gt;
&lt;h1&gt;十、插件系统 + IDE Bridge——Agent 进入生态&lt;/h1&gt;
&lt;p&gt;真实 CC 的最后两个大系统：插件生态（让其他人写扩展）和 IDE 桥接（让 CC 嵌入编辑器）。&lt;/p&gt;
&lt;h2&gt;10.1 插件系统——Extension Points&lt;/h2&gt;
&lt;p&gt;插件通过 &lt;code&gt;.claude-plugin/&lt;/code&gt; 目录中的声明文件扩展 CC：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;能力&lt;/th&gt;
&lt;th&gt;对应 s_full&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;commands/*.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;注册新的 &lt;code&gt;/&lt;/code&gt; 斜杠命令&lt;/td&gt;
&lt;td&gt;s05 skill 的超集&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agents/*.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;注册 Agent 定义（带工具白名单、system prompt、模型）&lt;/td&gt;
&lt;td&gt;s04 subagent 的可配置版&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hooks/hooks.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;注册 hook 回调&lt;/td&gt;
&lt;td&gt;s_full 完全没有&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plugin.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;元数据 + 版本 + 用户配置 schema&lt;/td&gt;
&lt;td&gt;s_full 完全没有&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;插件安装流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户: /plugin install my-plugin@marketplace
  │
  ▼
resolveDependencyClosure()       ← 解析依赖图
  │
  ▼
settings.json: enabledPlugins += 整个闭包
  │
  ▼
cacheAndRegisterPlugin()         ← 下载/拷贝到 ~/.claude/plugins/cache/
  │
  ▼
assemblePluginLoadResult()       ← 合并 marketplace + session + built-in
  │
  ▼
loadPluginCommands()             ← 注册斜杠命令
loadPluginAgents()               ← 注册 Agent 类型
loadPluginHooks()                ← 注册 hook 回调
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安全注意：插件 Agent 的安全敏感字段（&lt;code&gt;permissionMode&lt;/code&gt;、&lt;code&gt;hooks&lt;/code&gt;、&lt;code&gt;mcpServers&lt;/code&gt;）被&lt;strong&gt;强制忽略&lt;/strong&gt;——只有用户自己创建的本地 Agent 可以声明这些。防止恶意插件获得过高权限。&lt;/p&gt;
&lt;h2&gt;10.2 IDE Bridge——把 CC 嵌进编辑器&lt;/h2&gt;
&lt;p&gt;CC 通过两套协议和 VS Code / JetBrains 通信：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;REPL Bridge（WebSocket）：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VS Code Extension ←→ WebSocket ←→ CC Session Ingress Server
  │                                                    │
  │  SDKMessage 帧:                                     │
  │  - user/assistant turns                             │
  │  - slash commands                                   │
  │  - control requests (initialize, interrupt, etc.)   │
  │                                                    │
  │  Permission 回调:                                    │
  │  sendRequest(&quot;allow this tool?&quot;) ──────────────────→│
  │  ←────────────────────────── onResponse(allow/deny) │
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Remote Bridge（HTTP 轮询）：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CLI: claude remote-control
  │
  ├─ POST /v1/environments/bridge     ← 注册为 Bridge 环境
  ├─ GET  /v1/environments/{id}/work/poll   ← 轮询任务
  │    └─ 返回 WorkSecret: { ingressToken, apiBaseUrl, auths, env }
  │
  ├─ spawn 子进程 session              ← 用 WorkSecret 启动 CC 会话
  └─ POST heartbeat                    ← 续租
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bridge 支持三种 spawn 模式：单会话、worktree 隔离、同目录复用。&lt;/p&gt;
&lt;h2&gt;10.3 Skills vs Plugins——区别&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;格式&lt;/td&gt;
&lt;td&gt;Markdown + YAML frontmatter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude-plugin/&lt;/code&gt; 目录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;能力&lt;/td&gt;
&lt;td&gt;prompt 模板 + 参考文件&lt;/td&gt;
&lt;td&gt;commands + agents + hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;分发&lt;/td&gt;
&lt;td&gt;项目目录 &lt;code&gt;.claude/skills/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插件市场&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;版本&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;plugin.json 版本管理&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户配置&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;&lt;code&gt;userConfig&lt;/code&gt; schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;权限&lt;/td&gt;
&lt;td&gt;和主 Agent 一致&lt;/td&gt;
&lt;td&gt;Agent 权限受限（不能声明 permissionMode）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Skill 是 prompt，Plugin 是程序。s05 的 SkillLoader 对应真实 CC 的 Skill 系统（只是更简单）。插件是全新的——s_full 完全没有对应物。&lt;/p&gt;
&lt;h2&gt;10.4 和 s_full 的对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;s_full&lt;/th&gt;
&lt;th&gt;真实 CC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;扩展斜杠命令&lt;/td&gt;
&lt;td&gt;改 REPL 代码&lt;/td&gt;
&lt;td&gt;commands/*.md + plugin.json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自定义 Agent&lt;/td&gt;
&lt;td&gt;改 Python&lt;/td&gt;
&lt;td&gt;agents/*.md + 工具白名单&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;生态分发&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;marketplace + 版本管理 + 依赖解析&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IDE 集成&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;WebSocket REPL + HTTP Remote Bridge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;权限边界&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;插件 Agent 不能声明 permissionMode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;核心洞察&lt;/h2&gt;
&lt;p&gt;插件系统和 IDE Bridge 代表了 Agent 从&quot;工具&quot;走向&quot;平台&quot;的最后一步。s_full 是一个你 fork 然后改源码的 Python 脚本。真实 CC 是一个你不需要 fork 的生态——通过插件扩展能力、通过 MCP 接入外部工具、通过 Bridge 嵌入编辑器。&lt;/p&gt;
&lt;p&gt;这也解释了 512K 行代码从哪来：不是核心 Agent loop 变复杂了（它还是那个 while-tool_use），而是在这层循环周围长出了一个完整的平台——权限策略引擎、记忆持久化、hook 可编程接口、MCP 工具发现、7 种任务管理、插件市场、IDE 双向通信。&lt;strong&gt;骨架不膨胀，生态在骨架上生长。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;十一、总结——从 s01 到 512K 行，我们学到了什么&lt;/h1&gt;
&lt;p&gt;12 个 session + s_full + 五大真实系统。最终的图景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s01 — &quot;一个循环+一个Bash&quot;
  │
s02 — 工具分发（dispatch map）
  │
s03 — 内存规划（TodoWrite + nag）
s04 — 上下文隔离（Subagent）
s05 — 按需知识（Skills）
s06 — 策略遗忘（Compact）
  │
s07 — 磁盘任务图（DAG）    ← 第一个枢纽：状态在对话之外
s08 — 线程异步（Background）
  │
s09 — 多 Agent 邮箱（MessageBus）
s10 — 请求-响应 FSM（Protocols）
s11 — 自组织（Autonomous）   ← 第二个枢纽：Agent 自己找活干
s12 — 目录隔离（Worktree）
  │
s_full — 全机制集成（740行，骨架成型）
  │
真实 CC:
  权限引擎（allow/deny/ask + 6种模式 + AI 分类器）
  记忆系统（四种类型 + 自动提取 + Sonnet 侧查询）
  Hook 系统（28 事件 + 4 执行器 + updatedInput）
  MCP 集成（8 种传输 + 工具动态发现）
  任务系统（7 种类型 + 统一框架）
  插件生态 + IDE Bridge
  ─────────────────────────
  512,664 行 TypeScript
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三条主线贯穿始终：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;模型看管判断，harness 看管执行和约束&lt;/strong&gt; — 从 &lt;code&gt;&quot;dangerous&quot; in command&lt;/code&gt; 到完整的权限引擎，这个原则的粒度在变化，但方向没变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;加能力不改循环&lt;/strong&gt; — dispatch map 加一行、TOOLS 加一个 schema、hook 加一条配置。核心 &lt;code&gt;while stop_reason == &apos;tool_use&apos;&lt;/code&gt; 从 s01 到真实 CC 骨架不变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态在对话之外&lt;/strong&gt; — s03 内存 → s07 磁盘 → memory system 持久化 → task system 多类型。每一步都在把信息从模型上下文中拉出来，放到更持久的地方。&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;十二、社区热议——泄露源码中最令人惊喜的设计&lt;/h1&gt;
&lt;p&gt;Claude Code 源码在 2026 年 3 月 31 日因 npm 打包事故泄露后，社区花了数周逐行拆解这 1906 个文件、51.2 万行代码。以下是普遍认为写得最好、最出人意料、最值得学习的地方。&lt;/p&gt;
&lt;h2&gt;12.1 Prompt Cache 的三段式设计——静态/动态分离&lt;/h2&gt;
&lt;p&gt;这是被引用最多的设计亮点。CC 没有简单地把整个 system prompt 当成一个缓存块，而是精心设计了&lt;strong&gt;静态段和动态段的边界&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  静态段 (高缓存命中率)               │
│  模型身份 + 安全规则 + 代码风格限制    │  ← 每次对话都相同
├─────────────────────────────────────┤
│  SYSTEM_PROMPT_DYNAMIC_BOUNDARY      │  ← 硬编码分隔标记
├─────────────────────────────────────┤
│  动态段 (低缓存命中率)               │
│  工作目录 + Git 状态 + MCP 配置        │  ← 每次对话可能不同
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anthropic 的 prompt cache 按前缀匹配。如果动态内容放在静态内容前面，每次对话变化都会导致整个缓存失效。CC 把&lt;strong&gt;永不变化的内容放在最前面&lt;/strong&gt;（身份定义、安全规则），确保它们在 prompt cache 中始终命中。动态内容（当前目录、git 分支、MCP 服务器列表）放在后面。&lt;/p&gt;
&lt;p&gt;还有两个细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;工具描述按字母表排序&lt;/strong&gt; — 确保每次 &lt;code&gt;tools&lt;/code&gt; 数组的 JSON 序列化结果一致，避免缓存因 key 顺序变化而失效&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agent 列表外置到消息附件&lt;/strong&gt; — 减少 ~10.2% 的 cache creation tokens。这是一个微优化，但在大规模使用时累积效果显著&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12.2 自愈式记忆系统——&quot;不信任内存，不断回到代码库验证&quot;&lt;/h2&gt;
&lt;p&gt;社区的共识：这不是简单的&quot;记住用户说过什么&quot;，而是一套&lt;strong&gt;仿生学设计&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AutoDream——模型睡觉时整理记忆：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;触发条件:
  • 时间门: 距上次 &amp;gt;24h
  • 会话门: 累计 5 次会话
  • 文件锁: 防止多进程冲突

四阶段:
  收集 → 提取 → 去重合并 → 写入结构化文件
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;做梦&quot;这个名字不是玩笑——它在概念上和人类睡眠中的记忆巩固过程一致：白天的经历（对话），夜间整理（压缩、去重、结构化），醒来后能更快检索。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;9 段式 Compact 摘要结构：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;会话目标 → 已完成任务 → 未完成任务 → 关键决策 →
代码变更 → 发现问题 → 待验证假设 → 用户偏好 → 上下文关键信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不是把对话丢给模型让它&quot;总结一下&quot;，而是强制模型按 9 个维度结构化输出。这保证了压缩后的摘要&lt;strong&gt;可检索&lt;/strong&gt;——&quot;关键决策&quot;栏位可以快速判断压缩内容是否和当前问题相关。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;不信任内存&quot;哲学：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;记忆文件里明确写着：&quot;如果记忆中的信息和当前代码冲突，&lt;strong&gt;相信代码&lt;/strong&gt;。&quot;这个原则贯穿整个系统——记忆是提示，不是权威。模型应该每次回到代码库验证，而不是依赖记忆中的快照。&lt;/p&gt;
&lt;h2&gt;12.3 ToolSearch——工具的按需加载&lt;/h2&gt;
&lt;p&gt;40+ 个工具全塞进 prompt，每次 API 调用都带着——大部分当前任务用不到。CC 的解法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;核心工具 (always loaded):  bash, read, write, edit, TodoWrite, task
非核心工具 (defer_loading):  NotebookEdit, WebSearch, SkillTool, CronCreate...
                              ↑
                              标记 defer_loading: true
                              模型需要时通过 ToolSearch 关键词动态加载
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这相当于 Web 开发里的 &lt;strong&gt;Code Splitting&lt;/strong&gt;。模型在对话中第一次需要某个工具时，ToolSearch 才注入该工具的完整 schema。不是所有 40 个工具的 JSON 都一直占着上下文窗口。&lt;/p&gt;
&lt;h2&gt;12.4 六级安全架构——层层剥洋葱&lt;/h2&gt;
&lt;p&gt;社区从源码中还原的安全层级：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Layer 1: 静态危险命令拦截    → &quot;rm -rf /&quot; 等硬编码模式
Layer 2: 用户自定义规则       → settings.json 的 allow/deny/ask
Layer 3: 工具自身安全检查     → BashTool.checkPermissions() 的 1350 行逻辑
Layer 4: Sidecar AI 分类器    → 小模型静默判断（auto 模式）
Layer 5: 交互式 UI            → 弹出对话框让人决定
Layer 6: 独立沙箱执行         → 命令在隔离环境中跑
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;社区最赞赏的设计是 Layer 4：&lt;strong&gt;用一个小 LLM 去判断另一个 LLM 的操作是否安全。&lt;/strong&gt; 它比静态规则灵活（理解上下文），比人工审批快（毫秒级），而且有 Denial Tracking——如果小模型拒绝了太多次，系统自动降级到 Layer 5 让人类介入，防止误判卡住 Agent。&lt;/p&gt;
&lt;h2&gt;12.5 Coordinator + Fork Subagent——上下文污染的根治方案&lt;/h2&gt;
&lt;p&gt;s04 的 subagent 模式在真实 CC 中被放大为 Coordinator 架构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Coordinator (规划层)
  ├── 只能用 3 个工具：SpawnAgent / SendMessage / TaskStop
  ├── 不直接操作文件 ← 关键约束
  │
  ├─→ Worker A (方案A探索) ──→ 结论回传 (XML task-notification)
  ├─→ Worker B (方案B探索) ──→ 结论回传
  └─→ Worker C (并行任务)  ──→ 结论回传
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的精妙之处是 &lt;strong&gt;Fork 继承缓存&lt;/strong&gt;。创建 Worker 时，它 fork 父 Agent 的 prompt cache 前缀——&lt;strong&gt;不需要重新发送 system prompt&lt;/strong&gt;。创建一个子 Agent 的成本等同于发送一条 user message。这解释了为什么 CC 可以自由派发子 Agent 而不担心 token 成本爆炸。&lt;/p&gt;
&lt;p&gt;Worker 之间也互相隔离——Worker A 探索方案 A 时读了 20 个文件，这些文件内容不会污染 Worker B 的上下文。Coordinator 只收到每个 Worker 的&lt;strong&gt;结论&lt;/strong&gt;，不是完整探索日志。&lt;/p&gt;
&lt;h2&gt;12.6 泄露出的隐藏功能——最令人惊讶的部分&lt;/h2&gt;
&lt;p&gt;源码中还暴露了一些未发布或内部使用的功能：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;代号&lt;/th&gt;
&lt;th&gt;功能&lt;/th&gt;
&lt;th&gt;状态&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BUDDY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;电子宠物系统（18 物种、5 稀有度、1% 闪光概率）&lt;/td&gt;
&lt;td&gt;愚人节彩蛋&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KAIROS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7×24 常驻后台助手，支持 cron/webhook/远程控制&lt;/td&gt;
&lt;td&gt;未发布&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ULTRAPLAN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30 分钟深度规划模式（Opus 驱动）&lt;/td&gt;
&lt;td&gt;未发布&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Undercover Mode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;给开源仓库提 PR 时隐藏 Anthropic/AI 身份&lt;/td&gt;
&lt;td&gt;内部使用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Capybara&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;新模型族代号（疑似 Claude 4.6），百万上下文&lt;/td&gt;
&lt;td&gt;内部代号&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fennec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Opus 线模型代号&lt;/td&gt;
&lt;td&gt;内部代号&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;BUDDY（电子宠物）是最令人意外的一个——CC 终端里有一个完整的 Tamagotchi 式宠物系统，包含 18 种物种、5 个稀有度等级、1% 概率出现&quot;闪光&quot;变体。代码里有完整的&quot;喂食&quot;、&quot;玩耍&quot;、&quot;进化&quot;机制。社区普遍认为这是 Anthropic 工程师的&quot;hackathon 项目溜进了生产版本&quot;。&lt;/p&gt;
&lt;p&gt;KAIROS 则代表了一个远更大的野心——不只让你在终端里调一个 Agent，而是有一个常驻的 7×24 助手，能定时执行任务、响应 webhook、远程控制。&lt;/p&gt;
&lt;h2&gt;12.7 社区争议——&quot;vibe-coded garbage&quot; vs &quot;真实的复杂&quot;&lt;/h2&gt;
&lt;p&gt;源码被曝光后，Hacker News 上出现两极反应。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;批评阵营：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;print.ts&lt;/code&gt; 单函数超 3000 行，12 层嵌套&lt;/li&gt;
&lt;li&gt;大量 feature flag + 补丁式代码&lt;/li&gt;
&lt;li&gt;有工程师自嘲注释：&quot;memoization here increases complexity by a lot, and I&apos;m not sure it really improves performance&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;辩护阵营：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;看起来很乱，恰恰因为它进入了真实的高强度开发环境，而不再是实验室 demo&quot;&lt;/li&gt;
&lt;li&gt;这 51.2 万行代码处理的是真实世界的混乱——多终端兼容、shell 差异、文件系统权限、OAuth 流程、MCP 协议变体、跨平台 CI/CD&lt;/li&gt;
&lt;li&gt;一个开源社区成员一夜之间用 AI 工具从零重写了架构（&lt;code&gt;claw-code&lt;/code&gt;），2 小时内获得了 5 万+ Star——这说明&lt;strong&gt;架构好理解&lt;/strong&gt;，只是实现细节多&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最出圈的讽刺来自一位匿名评论者：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&quot;一家做 LLM 的公司居然用 regex 做情绪分析？就像卡车公司用马来运输零件。&quot;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;回应：&lt;em&gt;&quot;因为 regex 更快、更便宜，而且不会阻塞主流程。&quot;&lt;/em&gt; 这个对话完美概括了真实工程和学术理想的分野——最好的工具是刚好够用的工具。&lt;/p&gt;
&lt;h2&gt;12.8 这场&quot;被迫开源&quot;教会我们什么&lt;/h2&gt;
&lt;p&gt;CC 泄露事件的长期影响比源码本身更值得思考：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;护城河不在代码，在模型和数据&lt;/strong&gt; — Anthropic 在泄露后没有慌张，因为模型权重、训练数据和用户数据都没泄露。源码只是 harness——重要的，但不是不可替代的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&quot;Open by accident&quot; vs &quot;Open by design&quot;&lt;/strong&gt; — 同期 OpenAI 主动开源了 Codex CLI，而 Anthropic 是&quot;被迫&quot;暴露。两种策略折射出对竞争壁垒的不同理解。源码公开反而产生了社区认同——开发者 cleaner 用一晚上从零重写了同样的架构。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;架构的价值在于清晰&lt;/strong&gt; — 51.2 万行源码不是好东西因为它们&quot;复杂&quot;，是因为它们在复杂之上&lt;strong&gt;保持了可读的架构&lt;/strong&gt;。18 个 SECTION 标签、清晰的模块边界、一致的设计模式（dispatch map + JSON Schema）——这些我们在 s01-s12 里学到的东西，在真实 CC 里原样存在。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;核心洞察&lt;/h2&gt;
&lt;p&gt;这场泄露最出人意料的地方：Anthropic 用了最尴尬的方式，向全世界展示了他们真正在想什么。而社区普遍同意——&lt;strong&gt;想法比代码更值钱&lt;/strong&gt;。架构设计（prompt cache 三段式、Coordinator fork、六级安全、AutoDream 记忆）才是 CC 真正的竞争优势，而它们恰好是 leak 中最容易被复制的部分。&lt;/p&gt;
&lt;p&gt;这也是学习 harness engineering 的终极价值——你不需要 512K 行代码就能理解 CC。12 个 session、740 行 s_full，已经覆盖了它最核心的架构骨架。剩下的 511K 行是把这个骨架&lt;strong&gt;投入真实世界的摩擦代价&lt;/strong&gt;——多平台兼容、错误恢复、边界处理、UI 细节、企业合规。这些东西重要，但不是 harness 设计的本质。&lt;/p&gt;
</content:encoded></item><item><title>MCP和A2A--Agent的横向与纵向沟通</title><link>https://owen571.top/posts/study/%E9%9A%8F%E5%BF%83%E5%8D%9A%E5%AE%A2/mcp%E5%92%8Ca2a--agent%E7%9A%84%E6%A8%AA%E5%90%91%E4%B8%8E%E7%BA%B5%E5%90%91%E6%B2%9F%E9%80%9A/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E9%9A%8F%E5%BF%83%E5%8D%9A%E5%AE%A2/mcp%E5%92%8Ca2a--agent%E7%9A%84%E6%A8%AA%E5%90%91%E4%B8%8E%E7%BA%B5%E5%90%91%E6%B2%9F%E9%80%9A/</guid><description>MCP与A2A协议的横向与纵向沟通机制对比分析</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded/></item><item><title>Redis原理的学习</title><link>https://owen571.top/posts/study/%E9%9A%8F%E5%BF%83%E5%8D%9A%E5%AE%A2/redis%E5%8E%9F%E7%90%86%E7%9A%84%E5%AD%A6%E4%B9%A0/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E9%9A%8F%E5%BF%83%E5%8D%9A%E5%AE%A2/redis%E5%8E%9F%E7%90%86%E7%9A%84%E5%AD%A6%E4%B9%A0/</guid><description>Redis作为高效的NoSQL数据库，在LLM模型也很有用处，现在学习它的原理。</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;一、背景&lt;/h1&gt;
&lt;p&gt;Redis（Remote Dictionary Server）是一个开源的、基于内存的键值存储系统，属于 NoSQL 数据库阵营。它诞生的初衷是解决高并发场景下的性能瓶颈——传统关系型数据库将数据存储在磁盘上，每次读写都涉及磁盘 I/O，而 Redis 将数据直接放在内存中，单机 QPS 可达 10 万级别。&lt;/p&gt;
&lt;p&gt;Redis 的核心定位是&lt;strong&gt;缓存&lt;/strong&gt;，但它远不止于此。它支持丰富的数据结构（字符串、列表、集合、有序集合、哈希等），提供持久化（RDB 快照 + AOF 日志）、主从复制、哨兵高可用、集群分片等企业级特性。在现代架构中，Redis 常用于缓存加速、分布式锁、消息队列、排行榜、实时计数器等场景。在 LLM 应用中，它也被广泛用作向量存储、会话缓存和 Prompt 模板的管理层。&lt;/p&gt;
&lt;p&gt;Redis 之所以高效，除了内存存储外，还在于它对底层数据结构做了精心设计——本章将从这些基础组件开始，逐一拆解它&quot;快&quot;的秘密。&lt;/p&gt;
&lt;p&gt;此次拆解的源码是 Redis 8.6.3 版本。&lt;/p&gt;
&lt;h1&gt;二、组件&lt;/h1&gt;
&lt;h2&gt;1. 动态字符串 SDS&lt;/h2&gt;
&lt;h3&gt;和 C 字符串的区别&lt;/h3&gt;
&lt;p&gt;C 语言原生的字符串是以 &lt;code&gt;\0&lt;/code&gt; 结尾的 &lt;code&gt;char*&lt;/code&gt;，但这种方式存在几个缺陷：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;获取长度要 O(n)&lt;/strong&gt;：必须遍历整个字符串才能知道长度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;容易缓冲区溢出&lt;/strong&gt;：拼接字符串时，如果忘记分配足够内存，就会越界写入。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内存分配频繁&lt;/strong&gt;：每次修改字符串都要重新分配内存。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;二进制不安全&lt;/strong&gt;：遇到 &lt;code&gt;\0&lt;/code&gt; 就认为字符串结束，无法存储图片、序列化对象等二进制数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SDS（Simple Dynamic String）就是为解决这些问题而设计的。它本质上是在 C 字符串的基础上包了一层头部信息，用一个 &lt;code&gt;sds&lt;/code&gt; 类型（即 &lt;code&gt;char*&lt;/code&gt;，指向 buf 起始位置）对外暴露，对外依然兼容 C 字符串的用法。&lt;/p&gt;
&lt;h3&gt;SDS 结构体源码（带注解）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// sds.h —— 注意 struct 使用了 __attribute__ ((__packed__)) 取消对齐填充

/* sdshdr5 实际从未使用，仅用于标记布局 */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags;  /* 低 3 位存类型，高 5 位存长度（最多 31 字节） */
    char buf[];           // 柔性数组，存放实际字符串内容
};

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;          // 当前字符串长度（已用的字节数）
    uint8_t alloc;        // 已分配的总字节数（不含头、含&apos;\0&apos;）
    unsigned char flags;  // 低 3 位存类型标识，高 5 位未使用
    char buf[];           // 柔性数组，存放实际字符串内容
};

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;         // 同上，但宽度为 16 位
    uint16_t alloc;
    unsigned char flags;
    char buf[];
};

// 类似地还有 sdshdr32（32 位）和 sdshdr64（64 位），分别对应不同长度的字符串
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;flags&lt;/code&gt; 的低 3 位表示类型：&lt;code&gt;SDS_TYPE_5&lt;/code&gt;(0)、&lt;code&gt;SDS_TYPE_8&lt;/code&gt;(1)、&lt;code&gt;SDS_TYPE_16&lt;/code&gt;(2)、&lt;code&gt;SDS_TYPE_32&lt;/code&gt;(3)、&lt;code&gt;SDS_TYPE_64&lt;/code&gt;(4)。Redis 根据字符串长度自动选择最紧凑的头类型。对外暴露的 &lt;code&gt;sds&lt;/code&gt; 指针指向 &lt;code&gt;buf&lt;/code&gt; 的首地址，而不是结构体开头，因此通过 &lt;code&gt;s[-1]&lt;/code&gt; 就能直接读出 &lt;code&gt;flags&lt;/code&gt; 字节，进而知道该用哪种结构体去解析。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;实例：构建 &quot;name&quot;&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-2.F_ZErMwd.png&amp;amp;w=970&amp;amp;h=138&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;len = 4&lt;/code&gt;：当前字符串长度为 4&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alloc = 4&lt;/code&gt;：总共分配了 4 字节可用空间&lt;/li&gt;
&lt;li&gt;&lt;code&gt;flags&lt;/code&gt; 的低 3 位 = &lt;code&gt;SDS_TYPE_8&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buf&lt;/code&gt; 末尾自动追加了 &lt;code&gt;\0&lt;/code&gt;，兼容 C 库函数&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;实例：追加操作（&quot;hi&quot; → &quot;hi,Amy&quot;）&lt;/h3&gt;
&lt;p&gt;假设我们有一个 &lt;code&gt;sds s = &quot;hi&quot;&lt;/code&gt;，现在要调用 &lt;code&gt;sdscat(s, &quot;,Amy&quot;)&lt;/code&gt; 追加 4 个字节。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// sds.c —— _sdsMakeRoomFor()，负责扩容的核心逻辑
sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy) {
    size_t avail = sdsavail(s);      // 当前剩余空间
    size_t len = sdslen(s);          // 当前已用长度
    size_t newlen, reqlen;

    if (avail &amp;gt;= addlen) return s;   // 空间足够，直接返回

    reqlen = newlen = len + addlen;  // 至少需要的长度

    if (greedy == 1) {
        // 贪婪模式：多分配一些，避免下次再扩容
        if (newlen &amp;lt; SDS_MAX_PREALLOC)   // SDS_MAX_PREALLOC = 1MB
            newlen *= 2;                 // 小于 1MB 时直接翻倍
        else
            newlen += SDS_MAX_PREALLOC;  // 超过 1MB 后每次多给 1MB
    }
    // ... 然后根据 newlen 重新选择头类型，realloc 内存 ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;追加前 &quot;hi&quot; 的内存布局（假设 alloc = 2）：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-1.DKNY0UCg.png&amp;amp;w=706&amp;amp;h=128&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;追加 &quot;,Amy&quot; 时发现 &lt;code&gt;avail = 0&lt;/code&gt;，而 &lt;code&gt;newlen = 2 + 4 = 6&lt;/code&gt;。由于 &lt;code&gt;6 &amp;lt; 1MB&lt;/code&gt;，触发&lt;strong&gt;贪婪分配&lt;/strong&gt;：&lt;code&gt;newlen = 6 * 2 = 12&lt;/code&gt;。于是实际分配的空间为 12 字节，alloc 变成 12。&lt;/p&gt;
&lt;p&gt;追加后：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage.CkKMSmIm.png&amp;amp;w=1376&amp;amp;h=156&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;之后如果再追加少量字符（总长不超过 12），就不需要再次分配内存了——这正是&lt;strong&gt;内存预分配&lt;/strong&gt;的意义。&lt;/p&gt;
&lt;h3&gt;SDS 的优势总结&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;O(1) 获取长度&lt;/strong&gt;：直接读 &lt;code&gt;len&lt;/code&gt; 字段，无需遍历。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;杜绝缓冲区溢出&lt;/strong&gt;：每次修改前都会检查 &lt;code&gt;avail&lt;/code&gt;，不够就 &lt;code&gt;sdsMakeRoomFor&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;减少内存分配次数&lt;/strong&gt;：预分配（小于 1MB 翻倍，大于 1MB+1MB）使得频繁追加时很少 realloc。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;二进制安全&lt;/strong&gt;：不再以 &lt;code&gt;\0&lt;/code&gt; 作为字符串结束标志，而是以 &lt;code&gt;len&lt;/code&gt; 为准，可以存储任意二进制数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;惰性释放&lt;/strong&gt;：缩短字符串时并不立即释放多余空间，而是保留在 &lt;code&gt;alloc&lt;/code&gt; 中，方便后续再次增长。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. IntSet&lt;/h2&gt;
&lt;p&gt;IntSet 是 Redis 为「只包含整数的小型 Set」设计的底层存储结构。它的本质是一个基于 C 语言整数数组实现的&lt;strong&gt;有序、唯一、可变长&lt;/strong&gt;集合。&lt;/p&gt;
&lt;h3&gt;结构体定义&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// intset.h
typedef struct intset {
    uint32_t encoding;   // 编码方式：INTSET_ENC_INT16 / INT32 / INT64
    uint32_t length;     // 当前元素个数
    int8_t contents[];   // 柔性数组，实际存储的元素（类型由 encoding 决定）
} intset;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;encoding&lt;/code&gt; 取值：&lt;code&gt;INTSET_ENC_INT16&lt;/code&gt;（2 字节）、&lt;code&gt;INTSET_ENC_INT32&lt;/code&gt;（4 字节）、&lt;code&gt;INTSET_ENC_INT64&lt;/code&gt;（8 字节）。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contents&lt;/code&gt; 虽然声明为 &lt;code&gt;int8_t[]&lt;/code&gt;，但实际存储的类型由 &lt;code&gt;encoding&lt;/code&gt; 决定。所有元素在数组中保持&lt;strong&gt;升序排列且不重复&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;查找使用&lt;strong&gt;二分查找&lt;/strong&gt;，时间复杂度 O(logN)。插入和删除时需要 memmove 挪动元素，复杂度 O(N)。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;编码自动升级&lt;/h3&gt;
&lt;p&gt;假设当前 IntSet 中只有 &lt;code&gt;{1, 2, 3}&lt;/code&gt; 三个小整数，encoding 为 &lt;code&gt;INTSET_ENC_INT16&lt;/code&gt;（每个元素占 2 字节）。现在要插入 &lt;code&gt;50000&lt;/code&gt;，这个数超过了 int16 的范围（-32768 ~ 32767），就需要触发编码升级。&lt;/p&gt;
&lt;p&gt;升级流程的源码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// intset.c —— intsetUpgradeAndAdd()
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    uint8_t curenc = intrev32ifbe(is-&amp;gt;encoding);
    uint8_t newenc = _intsetValueEncoding(value);  // 确定新编码 → INTSET_ENC_INT32
    int length = intrev32ifbe(is-&amp;gt;length);
    int prepend = value &amp;lt; 0 ? 1 : 0;  // 负数插在数组头部，非负数插在尾部

    // 1. 先更新 encoding，再 resize 数组（每个元素空间变大）
    is-&amp;gt;encoding = intrev32ifbe(newenc);
    is = intsetResize(is, intrev32ifbe(is-&amp;gt;length) + 1);

    // 2. 倒序遍历旧元素，逐个搬移到新位置（倒序避免覆盖）
    while(length--)
        _intsetSet(is, length + prepend,
                    _intsetGetEncoded(is, length, curenc));

    // 3. 将新元素插入头部或尾部
    if (prepend)
        _intsetSet(is, 0, value);
    else
        _intsetSet(is, intrev32ifbe(is-&amp;gt;length), value);

    // 4. 更新 length
    is-&amp;gt;length = intrev32ifbe(intrev32ifbe(is-&amp;gt;length) + 1);
    return is;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以插入 &lt;code&gt;50000&lt;/code&gt;（正数，prepend = 0）为例，图解如下：&lt;/p&gt;
&lt;p&gt;插入前（INTSET_ENC_INT16）:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-5.BnnJyD5i.png&amp;amp;w=1700&amp;amp;h=200&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;升级后（INTSET_ENC_INT32）:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-4.B0rdTfjB.png&amp;amp;w=1680&amp;amp;h=186&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-3.ByTREyaJ.png&amp;amp;w=1374&amp;amp;h=144&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;关键细节：如果插入的是&lt;strong&gt;负数&lt;/strong&gt;（如 &lt;code&gt;-5&lt;/code&gt;），由于负数小于所有非负数，它会被放在头部（prepend = 1），原有元素整体右移一格。升级是&lt;strong&gt;不可逆&lt;/strong&gt;的——升级后即使删除了导致升级的那个元素，编码也不会降回去，这也能接受，因为一旦存过大值说明这个集合以后也&quot;大概率&quot;还会再存。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;IntSet 的特点&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;唯一且有序&lt;/strong&gt;：二分查找保证去重和查找为 O(logN)。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;编码升级机制&lt;/strong&gt;：从 INT16→INT32→INT64 自动扩展，不浪费空间在小数据上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不适合大量数据&lt;/strong&gt;：插入/删除需要移动元素（O(N)），当集合较大时性能下降明显，Redis 会将其转为 HashTable 编码。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. Dict&lt;/h2&gt;
&lt;p&gt;Dict（字典）是 Redis 最核心的数据结构之一，键值对的增删查改、哈希键的底层存储等，背后都是它。整个 Dict 由三个部分构成。&lt;/p&gt;
&lt;h3&gt;三部分结构&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// dict.h
typedef struct dictEntry {
    void *key;                    // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;                          // 值
    struct dictEntry *next;       // 链表解决哈希冲突
} dictEntry;

struct dict {
    dictType *type;               // 类型特定函数（哈希函数、比较、析构等）
    void *privdata;               // 私有数据
    dictEntry **ht_table[2];     // 两个哈希表，ht_table[0] 和 ht_table[1]
    unsigned long ht_used[2];    // 每个哈希表里已有的元素数
    long ht_size_exp[2];         // 每个哈希表的 size = 2^exp
    int16_t rehashidx;           // rehash 进度，-1 表示未在 rehash
    int16_t pauserehash;         // &amp;gt;0 时暂停 rehash
    unsigned bucket_size[2];     // 每个 bucket 的实际大小（内存优化）
};
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;dictEntry&lt;/strong&gt;：键值对节点，用单向链表解决哈希冲突。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dictEntry&lt;/strong&gt; 指针数组 &lt;code&gt;**ht_table[2]&lt;/code&gt;：这是真正的「哈希表」，可理解为 &lt;code&gt;dictEntry*  bucket数组&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dict&lt;/strong&gt;：字典主体，持有两个哈希表、两个 used 计数、rehash 游标等信息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-6.eZJw8r96.png&amp;amp;w=1686&amp;amp;h=606&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;索引计算：hash &amp;amp; sizemask&lt;/h3&gt;
&lt;p&gt;Redis 的哈希表大小始终是 &lt;strong&gt;2 的幂&lt;/strong&gt;（通过 &lt;code&gt;size = 2^exp&lt;/code&gt; 控制）。这样做的好处是可以通过位运算代替取模来定位 bucket：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bucket_index = hash(key) &amp;amp; sizemask
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中 &lt;code&gt;sizemask = size - 1&lt;/code&gt;。例如 size = 4，则 sizemask = 3（二进制 &lt;code&gt;011&lt;/code&gt;），对任意哈希值取低两位即可找到 bucket 位置。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Redis 使用 &lt;code&gt;dictGenHashFunction()&lt;/code&gt;（基于 SipHash，内部用 MurmurHash2），将 key 映射为一个 uint64_t。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;双哈希表&lt;/h3&gt;
&lt;p&gt;Dict 维护了两个哈希表 &lt;code&gt;ht_table[0]&lt;/code&gt; 和 &lt;code&gt;ht_table[1]&lt;/code&gt;。正常情况下只使用 &lt;code&gt;ht_table[0]&lt;/code&gt;，&lt;code&gt;ht_table[1]&lt;/code&gt; 是空的。
当需要进行扩容或收缩时，&lt;code&gt;ht_table[1]&lt;/code&gt; 被创建出来，用于&lt;strong&gt;渐进式 rehash&lt;/strong&gt;——一边把 &lt;code&gt;ht_table[0]&lt;/code&gt; 中的元素迁移到 &lt;code&gt;ht_table[1]&lt;/code&gt;，一边继续正常服务外部请求。&lt;/p&gt;
&lt;h3&gt;扩容与收缩的触发条件&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;扩容条件&lt;/strong&gt;（&lt;code&gt;dictExpandIfNeeded&lt;/code&gt; 中的逻辑）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当&lt;strong&gt;负载因子 ≥ 1&lt;/strong&gt; 且没有执行 BGSAVE / BGREWRITEAOF 等后台进程时，Redis 认为现在扩容是安全的，会触发扩容。&lt;/li&gt;
&lt;li&gt;当&lt;strong&gt;负载因子 &amp;gt; 5&lt;/strong&gt;（即 &lt;code&gt;dict_force_resize_ratio = 5&lt;/code&gt;），即使有后台进程也会强制扩容，因为哈希冲突已经太严重，性能下降不可接受。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;负载因子 = &lt;code&gt;ht_used / size&lt;/code&gt;，即每个 bucket 平均存放的元素个数。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;收缩条件&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当&lt;strong&gt;负载因子 &amp;lt; 0.1&lt;/strong&gt;（即不到 10% 的 bucket 真正有数据），且当前 size 大于初始值 &lt;code&gt;DICT_HT_INITIAL_SIZE&lt;/code&gt;（通常为 4），就会触发收缩。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;扩容时 size 翻倍，收缩时 size 减半，始终保证 size 是 2 的幂。&lt;/p&gt;
&lt;h3&gt;Rehash 过程（渐进式）&lt;/h3&gt;
&lt;p&gt;无论扩容还是收缩，都需要创建新的哈希表，并将旧表中所有 key 重新计算 bucket 索引后插入新表——这个过程叫 &lt;strong&gt;rehash&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果一次性完成，对于一个十几万 key 的字典会造成明显的卡顿。所以 Redis 使用&lt;strong&gt;渐进式 rehash&lt;/strong&gt;（Incremental Rehashing），把迁移任务分摊到多次操作中：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在 &lt;code&gt;dictExpand&lt;/code&gt; 中为 &lt;code&gt;ht_table[1]&lt;/code&gt; 分配新数组，并设置 &lt;code&gt;rehashidx = 0&lt;/code&gt;，表示 rehash 开始。&lt;/li&gt;
&lt;li&gt;每次对字典执行&lt;strong&gt;增删查改&lt;/strong&gt;操作时，除了完成本次操作，还会顺带把 &lt;code&gt;ht_table[0]&lt;/code&gt; 中位于 &lt;code&gt;rehashidx&lt;/code&gt; 的那条 bucket 链&lt;strong&gt;整条迁移&lt;/strong&gt;到 &lt;code&gt;ht_table[1]&lt;/code&gt;，然后 &lt;code&gt;rehashidx++&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;新增操作直接写入 &lt;code&gt;ht_table[1]&lt;/code&gt;，保证 &lt;code&gt;ht_table[0]&lt;/code&gt; 的元素只会减少不会增加。&lt;/li&gt;
&lt;li&gt;当 &lt;code&gt;rehashidx&lt;/code&gt; 等于 &lt;code&gt;ht_table[0]&lt;/code&gt; 的 size 时，表示迁移完成。释放 &lt;code&gt;ht_table[0]&lt;/code&gt;，将 &lt;code&gt;ht_table[1]&lt;/code&gt; 提升为 &lt;code&gt;ht_table[0]&lt;/code&gt;，重置 &lt;code&gt;rehashidx = -1&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;对于长时间没有请求的字典，每个事件循环也会在 &lt;code&gt;databasesCron&lt;/code&gt; 中执行 &lt;strong&gt;1ms 的 dictRehash&lt;/strong&gt;，每次处理 100 个 bucket，逐步消化。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-8.CGgx2tNR.png&amp;amp;w=1702&amp;amp;h=832&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-9.BUdRNa5E.png&amp;amp;w=1684&amp;amp;h=930&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-7.CXGMaGvi.png&amp;amp;w=1678&amp;amp;h=916&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. ZipList&lt;/h2&gt;
&lt;p&gt;ZipList（压缩列表）是 Redis 为了&lt;strong&gt;极致节省内存&lt;/strong&gt;而设计的一种特殊双向链表。它把所有元素压缩到一整块连续内存中，省去了传统链表每个节点所需的 &lt;code&gt;prev&lt;/code&gt; / &lt;code&gt;next&lt;/code&gt; 指针开销。&lt;/p&gt;
&lt;h3&gt;整体结构&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt; &amp;lt;zlbytes&amp;gt; &amp;lt;zltail&amp;gt; &amp;lt;zllen&amp;gt; &amp;lt;entry&amp;gt; &amp;lt;entry&amp;gt; ... &amp;lt;entry&amp;gt; &amp;lt;zlend&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;大小&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zlbytes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uint32_t&lt;/td&gt;
&lt;td&gt;整个 ziplist 占用的总字节数（含自身 4 字节）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zltail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uint32_t&lt;/td&gt;
&lt;td&gt;最后一个 entry 相对起始位置的偏移量，用于 O(1) 尾部操作&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zllen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uint16_t&lt;/td&gt;
&lt;td&gt;entry 数量，超过 65535 时设为 65535，需遍历获取真实数量&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;entry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;可变&lt;/td&gt;
&lt;td&gt;每个元素节点，详见下文&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zlend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uint8_t&lt;/td&gt;
&lt;td&gt;固定值 &lt;code&gt;255&lt;/code&gt;(0xFF)，标识 ziplist 结束&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;依托 &lt;code&gt;zltail&lt;/code&gt;，在尾部进行压入/弹出是 O(1)；依托每个 entry 的 &lt;code&gt;previous_entry_length&lt;/code&gt;，反向遍历也是 O(1)。但中间插入仍然是 O(N)，因为需要移动后续所有元素。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;Entry 结构&lt;/h3&gt;
&lt;p&gt;每个 entry 由三部分组成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; &amp;lt;prevlen&amp;gt; &amp;lt;encoding&amp;gt; &amp;lt;entry-data&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;prevlen&lt;/code&gt;（previous_entry_length）&lt;/strong&gt;：前一个 entry 的长度。如果前一个 entry 长度 &amp;lt; 254 字节，&lt;code&gt;prevlen&lt;/code&gt; 占 &lt;strong&gt;1 字节&lt;/strong&gt;直接记录；如果长度 ≥ 254 字节，&lt;code&gt;prevlen&lt;/code&gt; 占 &lt;strong&gt;5 字节&lt;/strong&gt;（首字节固定为 &lt;code&gt;0xFE&lt;/code&gt;，后 4 字节存实际长度）。这个字段是反向遍历的基础。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;encoding&lt;/code&gt;&lt;/strong&gt;：编码字段，同时描述了数据类型和长度。Redis 通过首字节的高 2 位来判断类型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;00&lt;/code&gt;、&lt;code&gt;01&lt;/code&gt;、&lt;code&gt;10&lt;/code&gt; 开头 → 字符串编码&lt;/strong&gt;：后续位存储字符串长度，长度分三档（≤63 / ≤16383 / ≥16384 字节），编码占 1/2/5 字节，之后紧跟 &lt;code&gt;entry-data&lt;/code&gt; 存实际字符串内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;11&lt;/code&gt; 开头 → 整数编码&lt;/strong&gt;：后 2 位区分具体整数类型（int16/int32/int64/24bit/8bit），编码占 3/5/9/4/2 字节，之后紧跟 &lt;code&gt;entry-data&lt;/code&gt; 存整数值。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一个特殊情形是 &lt;code&gt;|1111xxxx|&lt;/code&gt;（xxxx 在 0001~1101 之间）——&lt;strong&gt;4 位立即数编码&lt;/strong&gt;。它直接把数值 0~12 压缩在 encoding 字节的低 4 位中，值本身就成了编码的一部分，自然不需要额外的 &lt;code&gt;entry-data&lt;/code&gt;。同理，更大的整数类型（如 int16）的数值仍然需要单独的 &lt;code&gt;entry-data&lt;/code&gt; 来存放，因为 encoding 字节里只描述了「这是什么类型的整数」，放不下实际数值。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;entry-data&lt;/code&gt;&lt;/strong&gt;：实际数据内容，在某些整数编码下可能不存在。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;存储示例&lt;/h3&gt;
&lt;p&gt;存储 &lt;code&gt;&quot;ab&quot;&lt;/code&gt; 和 &lt;code&gt;&quot;bc&quot;&lt;/code&gt; 两个字符串（假设前一个 entry 长度均小于 254）：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-10.B23BT7-i.png&amp;amp;w=1718&amp;amp;h=272&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;存储数字 &lt;code&gt;2&lt;/code&gt; 和 &lt;code&gt;5&lt;/code&gt;（小整数编码，无需 entry-data）：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-11.Y3JzYJ8m.png&amp;amp;w=1354&amp;amp;h=266&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;连锁更新问题&lt;/h3&gt;
&lt;p&gt;考虑这样一种情况：ziplist 中有多个长度恰好为 &lt;strong&gt;253 字节&lt;/strong&gt; 的 entry，它们的 &lt;code&gt;prevlen&lt;/code&gt; 都只占 1 字节。现在在头部插入一个 &lt;strong&gt;254 字节以上&lt;/strong&gt; 的 entry，导致紧邻它的 entry1 的 &lt;code&gt;prevlen&lt;/code&gt; 必须从 1 字节膨胀到 5 字节（多出 4 字节），而 entry1 膨胀后又可能让 entry2 的 &lt;code&gt;prevlen&lt;/code&gt; 跟着膨胀……这种级联效应就是&lt;strong&gt;连锁更新&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;连锁更新在最坏情况下需要连续多次内存 realloc，性能下降明显。但这在现实中极难触发——需要大量恰好在 253 字节临界点附近的连续 entry。因此 Redis 并没有在代码层面做特殊防护，只是意识到这个问题存在。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;设计取舍：为什么 ZipList 坚持用 prevlen，直到 7.0 才被 Listpack 替代？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;prevlen 不是设计缺陷，而是一个明知故留的权衡。prevlen 放在 entry 头部，反向遍历时只需读当前 entry 的第一个字节就能知道前一个 entry 的长度，代码路径极短。Listpack 的 backlen 则需要往回读 1~5 字节（解析 continuation bits），稍复杂一些。antirez 认为这个简洁性值得用连锁更新的&lt;strong&gt;理论风险&lt;/strong&gt;来换——毕竟 Redis 的绝大多数 value 只有几十字节，连续多个&quot;恰好卡在 253&quot;的极端情况几乎不会发生。&lt;/p&gt;
&lt;p&gt;那为什么 7.0 最终还是改了？不是因为连锁更新在生产环境爆炸了，而是双通道复制等新特性需要一个对内存块边界更友好的格式，趁着重构顺带清掉了这笔历史债。本质上是「用稍复杂一点的反向遍历，换零连锁更新的完全保证」，在长期维护中后者胜出。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;5. QuickList&lt;/h2&gt;
&lt;p&gt;QuickList 是 Redis 3.2 之后 List 类型的底层实现，它在「内存紧凑」和「操作效率」之间找到了平衡。&lt;/p&gt;
&lt;h3&gt;基本结构&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-12.DWy9EHV9.png&amp;amp;w=1748&amp;amp;h=756&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;简单来说：&lt;strong&gt;QuickList 是一个双向链表，每个节点是一个 ZipList（新版使用 Listpack）&lt;/strong&gt;。外层链表用于快速定位到某个节点，内层的 ziplist/listpack 以紧凑的内存存储实际数据。&lt;/p&gt;
&lt;h3&gt;关键配置项&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;list-max-listpack-size&lt;/code&gt;&lt;/strong&gt;（旧称 &lt;code&gt;list-max-ziplist-size&lt;/code&gt;）：控制每个内部节点最大占用字节数。默认 -2（8KB）。可设为正数（精确字节限制）或负数（不同优化级别，如 -1 = 4KB，-2 = 8KB，-3 = 16KB，-4 = 32KB，-5 = 64KB）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;list-compress-depth&lt;/code&gt;&lt;/strong&gt;：控制 QuickList 的压缩深度。由于链表两端的节点访问频率最高，中间节点可以被 LZF 压缩以节省内存。设为 0 表示不压缩；设为 1 表示首尾各 1 个节点不压缩、中间全压缩；设为 2 表示首尾各 2 个不压缩，以此类推。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;list-compress-depth = 1:
  [node0 不压缩] → [node1 压缩] → [node2 压缩] → ... → [nodeN 不压缩]
     头                                               尾
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. SkipList&lt;/h2&gt;
&lt;p&gt;SkipList（跳表）是 Redis 中 ZSet（有序集合）的底层实现之一（另一个是 Listpack，用于数据量较小时）。它的本质是&lt;strong&gt;一个在有序链表上加了多层索引的数据结构&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;与普通链表的区别&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;元素按 score 升序排列&lt;/strong&gt;，score 相同时按 ele（成员字符串）字典序排列。&lt;/li&gt;
&lt;li&gt;每个节点可能包含&lt;strong&gt;多个层级指针&lt;/strong&gt;（称为 level），每个 level 的跨度（span）不同，高层指针用来&quot;跳过多余元素&quot;以加速查找。&lt;/li&gt;
&lt;li&gt;层数随机生成（最高 32 层），跳表的期望查询复杂度为 O(logN)。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;结构体定义&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// server.h
typedef struct zskiplistNode {
    double score;                      // 排序分值
    struct zskiplistNode *backward;    // 后退指针（仅 level 0 使用）
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 本层的前进指针
        unsigned long span;            // 本层跨过的元素个数
    } level[];                         // 柔性数组，每个元素表示一个层级
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 头尾指针
    unsigned long length;                 // 节点总数
    int level;                            // 当前最高层数
} zskiplist;

typedef struct zset {
    dict *dict;          // 字典：key→score，用于 O(1) 按成员查分值
    zskiplist *zsl;      // 跳表：按 score 排序，用于范围查询
} zset;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;ZSet 同时使用 dict 和 skiplist：dict 提供 O(1) 的成员分值查询，skiplist 提供 O(logN) 的范围查询和排序能力。两者存的是同一份数据（指针引用），内存不会翻倍。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;查询流程示意&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-13.Cc29Ov5t.png&amp;amp;w=1856&amp;amp;h=546&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;查找时从&lt;strong&gt;最高层&lt;/strong&gt;开始，如果前进指针指向的节点的 score 小于目标值，就沿着该层前进；如果大于目标值，就下降一层继续查找。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;span&lt;/code&gt; 记录了两个节点之间在 level 0 上跳过了多少个元素，用于计算排位（rank）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;与红黑树的对比&lt;/h3&gt;
&lt;p&gt;跳表和红黑树的增删查改复杂度都是 O(logN)，但跳表的&lt;strong&gt;实现更简单&lt;/strong&gt;：不需要复杂的旋转和变色逻辑，没有红黑树的多种情况分支。此外，跳表天然支持范围查询（直接沿 level 0 遍历），而且可以方便地获取元素的排位（通过 span 累加）。这些特性恰好契合 ZSet 的需求。&lt;/p&gt;
&lt;h2&gt;7. Listpack&lt;/h2&gt;
&lt;p&gt;Listpack 是 &lt;strong&gt;ZipList 的继任者&lt;/strong&gt;，Redis 7.0 起在所有场景中替代了 ZipList。它的定位完全一致——用一块连续内存紧凑存储多个元素，支持双向遍历、两端 O(1) 压入/弹出。区别在于它通过改变 entry 结构，从根本上消除了 ZipList 的&lt;strong&gt;连锁更新&lt;/strong&gt;问题。&lt;/p&gt;
&lt;h3&gt;整体结构&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt; &amp;lt;total_bytes&amp;gt; &amp;lt;num_elements&amp;gt; &amp;lt;entry&amp;gt; &amp;lt;entry&amp;gt; ... &amp;lt;entry&amp;gt; &amp;lt;0xFF&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;大小&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;total_bytes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uint32_t&lt;/td&gt;
&lt;td&gt;listpack 占用的总字节数（含自身 4 字节）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;num_elements&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uint16_t&lt;/td&gt;
&lt;td&gt;entry 数量，超过 65535 时取值为 65535，需遍历获取真实值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;entry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;可变&lt;/td&gt;
&lt;td&gt;每个元素节点&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0xFF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uint8_t&lt;/td&gt;
&lt;td&gt;结束标记&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;和 ZipList 的关键差异是：Listpack &lt;strong&gt;不再存 &lt;code&gt;zltail&lt;/code&gt;&lt;/strong&gt;，尾部定位改为从最后一条 entry 的 &lt;code&gt;backlen&lt;/code&gt; 反向推算，少了一个 4 字节的全局字段。&lt;/p&gt;
&lt;h3&gt;Entry 结构 —— 化解连锁更新的核心&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt; &amp;lt;encoding&amp;gt; &amp;lt;data&amp;gt; &amp;lt;backlen&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;encoding&lt;/code&gt;&lt;/strong&gt;：编码字节。高 2 位区分类型：&lt;code&gt;10&lt;/code&gt; 开头为短字符串（6 位长度，≤63 字节）；&lt;code&gt;1110&lt;/code&gt; 开头为中字符串（12 位长度，≤4095 字节）；&lt;code&gt;0&lt;/code&gt; 开头为小整数（7 位无符号，0~127）；&lt;code&gt;110&lt;/code&gt; 开头为 13 位整数；随后还有 16/24/32/64 位整数编码。与 ZipList 的 encoding 逻辑类似，但编码号不同。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;data&lt;/code&gt;&lt;/strong&gt;：实际数据内容。对于小整数（7 位编码），值直接嵌在 encoding 中，此部分不存在。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;backlen&lt;/code&gt;&lt;/strong&gt;：&lt;strong&gt;当前 entry 的总长度&lt;/strong&gt;（encoding + data + backlen 本身）。占 1~5 字节，通过每个字节的最高位（continuation bit）表示是否读下一字节。这是替代 ZipList &lt;code&gt;prevlen&lt;/code&gt; 的关键设计。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;为什么 Listpack 没有连锁更新&lt;/h3&gt;
&lt;p&gt;ZipList 的连锁更新根源在于 &lt;code&gt;prevlen&lt;/code&gt;：entry1 膨胀导致 entry2 的 &lt;code&gt;prevlen&lt;/code&gt; 也必须膨胀，级联扩散。Listpack 把视角翻了过来——每个 entry 记录的是&lt;strong&gt;自己的长度&lt;/strong&gt;（backlen），而不是前一个 entry 的长度。&lt;/p&gt;
&lt;p&gt;反向遍历时，从某个 entry 的起始位置往前回退一个 &lt;code&gt;backlen&lt;/code&gt; 就是前一个 entry 的起点。一个 entry 的 &lt;code&gt;backlen&lt;/code&gt; 只取决于自身的总大小，与前后 entry 无关，因此任何 entry 的修改都不会触发邻居的连锁连带更新。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;从 entry3 反向遍历到 entry2：
  entry2_start = entry3_start - entry2_backlen

从 entry2 反向遍历到 entry1：
  entry1_start = entry2_start - entry1_backlen
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;与 ZipList 的对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;ZipList&lt;/th&gt;
&lt;th&gt;Listpack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;连续内存&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;反向遍历&lt;/td&gt;
&lt;td&gt;靠 &lt;code&gt;prevlen&lt;/code&gt;（记录前一个 entry 长度）&lt;/td&gt;
&lt;td&gt;靠 &lt;code&gt;backlen&lt;/code&gt;（记录当前 entry 长度）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;连锁更新&lt;/td&gt;
&lt;td&gt;存在（prevlen 级联膨胀）&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;不存在&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;头部字段&lt;/td&gt;
&lt;td&gt;zlbytes / zltail / zllen（10 字节）&lt;/td&gt;
&lt;td&gt;total_bytes / num_elements（6 字节）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;首次引入&lt;/td&gt;
&lt;td&gt;远古版本&lt;/td&gt;
&lt;td&gt;Redis 7.0，7.2 起全面替代&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;本文后续提到&quot;小对象优化用 ZipList 做紧凑存储&quot;的地方，在 Redis 7.0+ 中实际运行时都是 Listpack，但编码思想和应用场景完全一致。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;小结：七种组件分工&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;角色&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;动态字符串，承载所有文本/二进制值的存储&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IntSet&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;小整数集合，有序、二分查找&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dict&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;哈希表，O(1) 增删查改，支持渐进式 rehash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ZipList / Listpack&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;紧凑连续内存块，小数据量下的省内存方案&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;QuickList&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;双向链表 + Listpack 节点，List 的默认实现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SkipList&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;多层有序跳表，O(logN) 范围查询和排名&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这七种组件是 Redis 的全部&quot;积木&quot;。下一章会看到它们如何被组装成对外的五种数据结构。&lt;/p&gt;
&lt;h1&gt;三、RedisObject —— 类型系统的桥接层&lt;/h1&gt;
&lt;p&gt;第二章讲的是七种存储实现，第四章要讲五种对外数据类型。那么问题来了：&lt;strong&gt;Redis 怎么知道一个 String 该用 INT、EMBSTR 还是 RAW？一个 Set 什么时候用 IntSet、什么时候用 Dict？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;答案就是 RedisObject。它不是存储实现，而是&lt;strong&gt;类型分发器&lt;/strong&gt;——用两个 4 位的字段 &lt;code&gt;type&lt;/code&gt; 和 &lt;code&gt;encoding&lt;/code&gt;，把「数据类型」映射到「底层实现」。&lt;/p&gt;
&lt;h3&gt;结构体定义&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// object.h
struct redisObject {
    unsigned type:4;                    // 4 位：数据类型（OBJ_STRING/OBJ_LIST/...）
    unsigned encoding:4;               // 4 位：底层编码方式（七种组件选其一）
    unsigned refcount : OBJ_REFCOUNT_BITS;  // 引用计数（23 位）
    unsigned iskvobj : 1;              // 是否为 kvobj（键值一体对象）
    unsigned metabits : 8;             // 附加元数据位图（仅在 iskvobj=1 时有效）
    unsigned lru : LRU_BITS;           // 24 位：LRU 时钟或 LFU 计数器
    void *ptr;                         // 8 字节：指向实际数据的指针
};
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;总计 16 字节。&lt;code&gt;type&lt;/code&gt; 决定「是什么」，&lt;code&gt;encoding&lt;/code&gt; 决定「怎么存」，&lt;code&gt;ptr&lt;/code&gt; 指向真正的存储实现。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;编码方式一览&lt;/h3&gt;
&lt;p&gt;Redis 的编码常量定义在 &lt;code&gt;object.h&lt;/code&gt; 中：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;编号&lt;/th&gt;
&lt;th&gt;常量&lt;/th&gt;
&lt;th&gt;对应组件&lt;/th&gt;
&lt;th&gt;目前状态&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_RAW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SDS&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_INT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;无（ptr 直接存值）&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_HT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dict&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_ZIPMAP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;已废弃&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_LINKEDLIST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;已废弃&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_ZIPLIST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ZipList&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;已废弃&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_INTSET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IntSet&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_SKIPLIST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dict + SkipList&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_EMBSTR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SDS（嵌入式）&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_QUICKLIST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;QuickList&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_STREAM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Radix Tree + Listpack&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_LISTPACK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Listpack&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_LISTPACK_EX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Listpack（扩展）&lt;/td&gt;
&lt;td&gt;使用中&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;lru 字段 —— 内存淘汰的近似 LRU&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;redisObject&lt;/code&gt; 中的 &lt;code&gt;lru&lt;/code&gt;（24 位）字段看起来和类型分发无关，它的职责是&lt;strong&gt;内存淘汰&lt;/strong&gt;——当 Redis 使用内存超过 &lt;code&gt;maxmemory&lt;/code&gt; 上限时，决定哪些 key 优先被清理。&lt;/p&gt;
&lt;p&gt;和常见八股题（HashMap + 双向链表实现精确 LRU）不同，Redis 用的是&lt;strong&gt;近似 LRU&lt;/strong&gt;。如果给几百万个 key 维护一个全局双向链表，每次访问都要加锁移动节点，内存和 CPU 开销根本扛不住。&lt;/p&gt;
&lt;p&gt;实现代码在 &lt;code&gt;evict.c&lt;/code&gt; 中，思路很简单：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 记录时间戳而非链表指针。&lt;/strong&gt; &lt;code&gt;lru&lt;/code&gt; 存的是一个 24 位的秒级时钟值（&lt;code&gt;mstime() / 1000&lt;/code&gt;，约 194 天转一圈）。每次访问一个 key 时，&lt;code&gt;lookupKey()&lt;/code&gt; 顺手把 &lt;code&gt;lru&lt;/code&gt; 更新为当前时钟——只是一个赋值，O(1)。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 淘汰时采样，不全局排序。&lt;/strong&gt; 需要释放内存时，随机取 N 个 key（&lt;code&gt;maxmemory-samples&lt;/code&gt;，默认 5），计算它们的空闲时间（&lt;code&gt;当前时钟 - key.lru&lt;/code&gt;），把最久没访问的那些塞进一个大小为 16 的候选池（evictionPool），从中挑最老的那个淘汰。没释放够就继续采样、继续淘汰。&lt;/p&gt;
&lt;p&gt;换句话说，它淘汰的不是「全 Redis 最久未访问的 key」，而是「几次随机采样中看起来最旧的 key」。牺牲一点精确度，换来零额外内存和无需全局锁。&lt;/p&gt;
&lt;p&gt;淘汰策略由 &lt;code&gt;maxmemory-policy&lt;/code&gt; 配置，共 10 种：&lt;code&gt;noeviction&lt;/code&gt;（不淘汰，写操作直接报错）、&lt;code&gt;volatile-lru&lt;/code&gt; / &lt;code&gt;allkeys-lru&lt;/code&gt;（近似 LRU，按范围看是否只看有过期时间的 key）、&lt;code&gt;volatile-lfu&lt;/code&gt; / &lt;code&gt;allkeys-lfu&lt;/code&gt;（近似 LFU，&lt;code&gt;lru&lt;/code&gt; 字段改为存访问频率计数器）、&lt;code&gt;volatile-ttl&lt;/code&gt;（最接近过期的先淘汰）、&lt;code&gt;volatile-random&lt;/code&gt; / &lt;code&gt;allkeys-random&lt;/code&gt;（随机淘汰）、以及 &lt;code&gt;volatile-lrm&lt;/code&gt; / &lt;code&gt;allkeys-lrm&lt;/code&gt;（LRU 采样后二次筛选的变体）。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;LFU 模式下 &lt;code&gt;lru&lt;/code&gt; 字段的语义变了：高 16 位存上次衰减时间，低 8 位存对数计数器的访问频率。核心仍然是「用一个 int 字段替代全局链表」。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;分发逻辑&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;  高层数据类型（type 字段）           底层实现（encoding 字段）
 ─────────────────────────         ───────────────────────────
  String  ────┬──────────────→  INT / EMBSTR / RAW
  List    ────┼──────────────→  QUICKLIST
  Set     ────┼──────────────→  INTSET ──→ HT
  ZSet    ────┼──────────────→  LISTPACK ──→ SKIPLIST
  Hash    ────┼──────────────→  LISTPACK ──→ HT
  Stream  ────┘──────────────→  STREAM

  判断逻辑由 RedisObject 的 encoding 字段驱动，
  每种数据结构的 *.c 文件中各自维护了切换阈值。
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;四、五种数据结构&lt;/h1&gt;
&lt;p&gt;前两章分别拆解了存储组件和类型系统，这一章看最上层——五种对用户暴露的数据结构。它们不是独立的数据结构，而是底层组件经过 RedisObject 的 encoding 分发后，呈现给用户的&quot;最终形态&quot;。理解每种数据结构在什么条件下选用哪种底层编码，是读懂 Redis 性能模型的关键。&lt;/p&gt;
&lt;h2&gt;1. String&lt;/h2&gt;
&lt;p&gt;String 是 Redis 中最基础的数据类型，一个键对应一个字符串值，值可以是普通文本、整数、二进制数据，最大 512MB。&lt;/p&gt;
&lt;h3&gt;三种内部编码&lt;/h3&gt;
&lt;p&gt;String 并非总是用 SDS 来存储。根据值的内容和长度，Redis 会选择三种编码之一：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;编码&lt;/th&gt;
&lt;th&gt;常量&lt;/th&gt;
&lt;th&gt;触发条件&lt;/th&gt;
&lt;th&gt;内部存储方式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;INT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_INT&lt;/code&gt; (1)&lt;/td&gt;
&lt;td&gt;值可以解析为整数，且范围在 &lt;code&gt;LONG_MIN&lt;/code&gt; ~ &lt;code&gt;LONG_MAX&lt;/code&gt; 内&lt;/td&gt;
&lt;td&gt;指针直接存数值，不额外分配内存&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EMBSTR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_EMBSTR&lt;/code&gt; (8)&lt;/td&gt;
&lt;td&gt;字符串长度 ≤ 44 字节&lt;/td&gt;
&lt;td&gt;RedisObject 和 SDS 在同一块连续内存中，一次分配&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAW&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OBJ_ENCODING_RAW&lt;/code&gt; (0)&lt;/td&gt;
&lt;td&gt;字符串长度 &amp;gt; 44 字节&lt;/td&gt;
&lt;td&gt;RedisObject 和 SDS 分别两次分配内存&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;编码选择流程（对应源码）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// object.c —— createStringObject()
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44

robj *createStringObject(const char *ptr, size_t len) {
    if (len &amp;lt;= 44)  return createEmbeddedStringObject(ptr, len);  // → EMBSTR
    else             return createRawStringObject(ptr, len);       // → RAW
}

// object.c —— createStringObjectFromLongLongWithOptions()
robj *createStringObjectFromLongLongWithOptions(long long value, ...) {
    // 小整数（0~9999）优先使用共享对象，避免重复分配
    if (value &amp;gt;= 0 &amp;amp;&amp;amp; value &amp;lt; 10000)
        return shared.integers[value];

    // 范围内整数直接存 ptr 中
    o-&amp;gt;encoding = OBJ_ENCODING_INT;
    o-&amp;gt;ptr = (void*)((long)value);   // ptr 不指向内存，直接存数值
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;EMBSTR 的 44 字节限制是精心计算的：&lt;code&gt;redisObject&lt;/code&gt; 占 16 字节，&lt;code&gt;sdshdr8&lt;/code&gt; 头占 3 字节，加上 44 字节数据和一个 &lt;code&gt;\0&lt;/code&gt; 共 64 字节，正好对齐 jemalloc 的 64 字节 arena，一次 &lt;code&gt;malloc&lt;/code&gt; 即可，分配和释放效率极高。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;编码的内存布局对比&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-14.pi4k6mjw.png&amp;amp;w=1812&amp;amp;h=874&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;编码转换&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;INT → RAW&lt;/strong&gt;：对 INT 编码的对象执行 &lt;code&gt;append&lt;/code&gt; 等字符串操作时，会自动转为 RAW。因为 INT 编码下 &lt;code&gt;ptr&lt;/code&gt; 存的是数字而非 SDS 指针，无法进行字符串拼接。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EMBSTR → RAW&lt;/strong&gt;：EMBSTR 分配的内存是只读连续的，任何修改操作（如 &lt;code&gt;append&lt;/code&gt;）都会触发重新分配，转为 RAW 编码。这是 EMBSTR 的唯一代价——它只适用于不变的短字符串。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;INT 优化&lt;/strong&gt;：当对 String 执行 &lt;code&gt;incr&lt;/code&gt;、&lt;code&gt;decr&lt;/code&gt; 等数值操作时，Redis 会尝试将 RAW/EMBSTR 转为 INT。&lt;code&gt;tryObjectEncoding()&lt;/code&gt; 函数负责这一优化。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. List&lt;/h2&gt;
&lt;p&gt;List 是一个有序的字符串列表，支持从两端压入/弹出，典型的使用场景是消息队列、最新动态列表等。&lt;/p&gt;
&lt;h3&gt;编码演进&lt;/h3&gt;
&lt;p&gt;Redis 3.2 是一个分水岭：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;版本&lt;/th&gt;
&lt;th&gt;编码方式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3.2 之前&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;小数据用 &lt;code&gt;ZipList&lt;/code&gt;（连续内存块），大数据用 &lt;code&gt;LinkedList&lt;/code&gt;（真正的双向链表）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3.2 及之后&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;统一切换为 &lt;code&gt;QuickList&lt;/code&gt;（双向链表 + 每个节点的 Listpack/ZipList）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;旧方案中 ZipList 省内存但中间插入慢，LinkedList 插入快但每个节点都有 &lt;code&gt;prev&lt;/code&gt;/&lt;code&gt;next&lt;/code&gt; 指针开销，内存碎片严重。QuickList 折中了二者——外层链表控制粒度，内层 listpack 保持内存紧凑。目前 List 只使用 QuickList 这一种编码，不再需要在小数据量和大数据量之间切换。&lt;/p&gt;
&lt;h3&gt;QuickList 的配置回顾&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;list-max-listpack-size -2   # 每个内部节点最大 8KB（负数按 4K/8K/16K/32K/64K 分级）
list-compress-depth 0       # 中间节点 LZF 压缩深度，0=不压缩
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个参数在第二章第 5 节已有详细说明，这里不再展开。&lt;/p&gt;
&lt;h3&gt;典型操作&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;LPUSH mylist &quot;world&quot;    →  [&quot;world&quot;]
LPUSH mylist &quot;hello&quot;    →  [&quot;hello&quot;, &quot;world&quot;]
RPUSH mylist &quot;!&quot;        →  [&quot;hello&quot;, &quot;world&quot;, &quot;!&quot;]
LRANGE mylist 0 -1      →  [&quot;hello&quot;, &quot;world&quot;, &quot;!&quot;]
LPOP mylist             →  &quot;hello&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;底层对 QuickList 头尾节点的 push/pop 都是 O(1)，而 &lt;code&gt;LRANGE&lt;/code&gt; 等范围查询需要遍历节点内的 listpack 元素。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-15.CtvHUR5E.png&amp;amp;w=1820&amp;amp;h=582&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;3. Set&lt;/h2&gt;
&lt;p&gt;Set 是一个无序、唯一的字符串集合，支持交集、并集、差集等集合运算，常用于标签系统、共同好友、随机抽奖等场景。&lt;/p&gt;
&lt;h3&gt;编码方式&lt;/h3&gt;
&lt;p&gt;Set 有两种底层编码，会根据数据特征自动选择：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. INTSET（OBJ_ENCODING_INTSET）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当集合同时满足两个条件时使用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有元素都是&lt;strong&gt;整数&lt;/strong&gt;（或能解析为整数）&lt;/li&gt;
&lt;li&gt;元素数量 ≤ &lt;code&gt;set-max-intset-entries&lt;/code&gt;（默认 512）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;此时 Set 就是一个 IntSet（见第二章第 2 节），元素有序存储、二分查找去重，非常省内存。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. HT（OBJ_ENCODING_HT）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦不满足上述条件（比如加入了非整数元素，或数量超阈值），Set 会转为 HashTable 编码。这里直接复用了 Dict 结构，具体做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dict 的 &lt;strong&gt;key&lt;/strong&gt; 存集合元素&lt;/li&gt;
&lt;li&gt;Dict 的 &lt;strong&gt;value&lt;/strong&gt; 统一设为 &lt;code&gt;NULL&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，Set 本质上是一个「只有键没有值」的哈希表。这样做的好处是代码复用度极高——增删查改直接走 Dict 的 &lt;code&gt;dictAdd&lt;/code&gt;/&lt;code&gt;dictDelete&lt;/code&gt;/&lt;code&gt;dictFind&lt;/code&gt;，不需要专门为 Set 写一套哈希表。&lt;/p&gt;
&lt;h3&gt;编码转换触发&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// t_set.c —— intset 元素数量超限时转换
static void maybeConvertIntset(robj *subject) {
    if (intsetLen(subject-&amp;gt;ptr) &amp;gt; intsetMaxEntries())   // 默认 &amp;gt; 512
        setTypeConvert(subject, OBJ_ENCODING_HT);        // 转为哈希表
}

// t_set.c —— 新元素不是整数时直接转 HT
if (!isSdsRepresentableAsLongLong(value, NULL)) {
    setTypeConvertAndExpand(set, OBJ_ENCODING_HT, ...);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;注意：INTSET → HT 是&lt;strong&gt;单向的&lt;/strong&gt;，转过去后即使把所有非整数元素删掉，也不会降级回 INTSET。这是因为「曾有过非整数，以后大概率还会有」。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;内存布局示意&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-16.DGgsv8WK.png&amp;amp;w=1748&amp;amp;h=608&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. ZSet&lt;/h2&gt;
&lt;p&gt;ZSet（Sorted Set，有序集合）在 Set 的基础上为每个元素绑定了一个 &lt;strong&gt;score（分值）&lt;/strong&gt;，元素按 score 升序排列，score 相同时按元素字符串字典序排列。典型应用是排行榜、延迟队列、带权重的标签。&lt;/p&gt;
&lt;h3&gt;双结构混合编码（SKIPLIST）&lt;/h3&gt;
&lt;p&gt;当数据量较大时，ZSet 使用 &lt;strong&gt;两种结构配合&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct zset {
    dict *dict;          // 字典：element → score，O(1) 按元素查分值
    zskiplist *zsl;      // 跳表：按 score 排序，O(logN) 范围查询 + 排名
} zset;
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;操作&lt;/th&gt;
&lt;th&gt;用哪个结构&lt;/th&gt;
&lt;th&gt;复杂度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;查某个元素的 score&lt;/td&gt;
&lt;td&gt;dict&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;按 score 范围查元素（ZRANGEBYSCORE）&lt;/td&gt;
&lt;td&gt;skiplist&lt;/td&gt;
&lt;td&gt;O(logN + M)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查某个元素的排名（ZRANK）&lt;/td&gt;
&lt;td&gt;skiplist（累加 span）&lt;/td&gt;
&lt;td&gt;O(logN)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;添加/更新元素&lt;/td&gt;
&lt;td&gt;dict + skiplist 同步操作&lt;/td&gt;
&lt;td&gt;O(logN)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;为什么不用单一结构？&lt;/strong&gt; 仅用 dict 拿不到排序和排名，仅用 skiplist 按元素查分值要从最高层一路找（O(logN)），不如 O(1) 的 dict 快。两者存同一份数据的指针引用，不会造成内存翻倍——dict 的 key 和 skiplist 的 ele 指向同一个 SDS 对象。&lt;/p&gt;
&lt;h3&gt;小对象优化：LISTPACK 编码&lt;/h3&gt;
&lt;p&gt;当数据量较小时，跳表的层指针和 dict 的 bucket 数组的开销就显得不划算。此时 Redis 切换为紧凑的 &lt;strong&gt;LISTPACK&lt;/strong&gt;（旧版叫 ZipList）编码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;触发条件（两个都满足才使用 LISTPACK）：
  1. 元素数量 ≤ zset-max-listpack-entries（默认 128）
  2. 每个元素的字符串长度 ≤ zset-max-listpack-value（默认 64 字节）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Listpack 本身没有排序和键值对概念，Redis 通过编码约定来解决：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Listpack 中 ZSet 元素的排列方式：
  [ele1, score1, ele2, score2, ele3, score3, ...]

即相邻两个 entry 构成一对 (element, score)，按 score 从小到大排列。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当插入新元素时，Redis 会遍历 listpack 找到正确的插入位置（保持 score 有序），然后用 memmove 挪出空间。这也是为什么 listpack/zset 只在数据量小时使用——数据量大后 memmove 的开销不可接受。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;有序是谁的责任？&lt;/strong&gt; 一个容易混淆的点：SkipList 的有序是数据结构自带的——节点按 score 链式排列，插入时走高层指针定位，O(logN)。Listpack 本身只是一个紧凑数组，没有排序能力——它的有序是 &lt;code&gt;t_zset.c&lt;/code&gt; 的插入代码「逐对遍历、比大小、找位置、memmove」强行维系的。两者都服务于 ZSet 的排序需求，只是实现路径不同：前者靠结构，后者靠操作。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;编码切换&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;LISTPACK → SKIPLIST：
  当插入后元素数量 &amp;gt; 128 或新 ele 长度 &amp;gt; 64 字节时触发升级

SKIPLIST → LISTPACK：
  当删除后剩余元素 ≤ 128 且最大 ele ≤ 64 字节时，会降级回 LISTPACK
  （与 Set 的 INTSET 不同，这里是双向的，通过 zsetConvertToListpackIfNeeded 实现）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;结构示意图&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-17.Dj0M3Rwr.png&amp;amp;w=1670&amp;amp;h=354&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;5. Hash&lt;/h2&gt;
&lt;p&gt;Hash 是一个字段-值的映射（field → value），适合存储对象（如用户信息、商品属性）。它与 ZSet 极其相似，本质上是「去掉了排序需求的 ZSet」。&lt;/p&gt;
&lt;h3&gt;与 ZSet 的相似与区别&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;ZSet&lt;/th&gt;
&lt;th&gt;Hash&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;元素结构&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(element, score)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(field, value)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;排序&lt;/td&gt;
&lt;td&gt;按 score 排序&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;无序&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查单个&lt;/td&gt;
&lt;td&gt;O(1) 或 O(logN)&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;大对象编码&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SKIPLIST&lt;/code&gt;（dict + skiplist）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HT&lt;/code&gt;（仅 dict）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;小对象编码&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LISTPACK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LISTPACK&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;小对象内部排列&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[ele, score, ele, score, ...]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[field, value, field, value, ...]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;关键差异一句话：&lt;strong&gt;Hash 不需要排序，所以把 ZSet 大对象编码中的 skiplist 去掉，只保留 dict，就是 Hash 的大对象编码。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;编码方式&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. LISTPACK（小数据）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;触发条件（两个都满足）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;元素数量 ≤ &lt;code&gt;hash-max-listpack-entries&lt;/code&gt;（默认 512）&lt;/li&gt;
&lt;li&gt;每个 field 和 value 的长度 ≤ &lt;code&gt;hash-max-listpack-value&lt;/code&gt;（默认 64 字节）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Listpack 中相邻两个 entry 为一对 &lt;code&gt;[field, value]&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Listpack 中 Hash 元素的排列：
  [field1, value1, field2, value2, field3, value3, ...]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查找时遍历 listpack，逐个匹配 field，找到后返回紧随其后的 value。因为没有排序需求，插入时直接追加到末尾（或覆盖已有 field 的 value），不需要像 ZSet 那样查找排序位置。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. HT（大数据）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一旦超出小对象阈值，Hash 转为纯粹的 Dict 编码（&lt;code&gt;OBJ_ENCODING_HT&lt;/code&gt;）。Dict 的 key 存 field，value 存实际值。这是 Hash 最通用的形态——O(1) 查找、O(1) 平均插入。&lt;/p&gt;
&lt;h3&gt;编码转换&lt;/h3&gt;
&lt;p&gt;Hash 的编码切换逻辑与 ZSet 基本一致，摘出核心代码（&lt;code&gt;t_hash.c&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// t_hash.c —— hashTypeSet()
if (sdslen(field) &amp;gt; server.hash_max_listpack_value ||
    sdslen(value) &amp;gt; server.hash_max_listpack_value)
    hashTypeConvert(db, o, OBJ_ENCODING_HT);       // 单条数据过大 → 转 HT

if (hashTypeLength(o, 0) &amp;gt; server.hash_max_listpack_entries)
    hashTypeConvert(db, o, OBJ_ENCODING_HT);       // 元素总数超限 → 转 HT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;与 ZSet 不同的是，Hash 从 HT 回退到 LISTPACK 需要显式触发（如 &lt;code&gt;HSCAN&lt;/code&gt; 扫描检测），不会像 ZSet 那样在每次删除时主动检查降级。&lt;/p&gt;
&lt;h3&gt;五种数据结构总结&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;String  ── INT / EMBSTR / RAW
List    ── QUICKLIST
Set     ── INTSET ──────────→ HT (dict, value=NULL)
ZSet    ── LISTPACK ────────→ SKIPLIST (dict + skiplist)
Hash    ── LISTPACK ────────→ HT (dict, field→value)

         ← 小数据，内存紧凑 →  ← 大数据，查询高效 →
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;这五种数据结构的设计思想一以贯之：「小对象用连续内存（INT/EMBSTR/LISTPACK/INTSET），大对象用索引结构（RAW/HT/SKIPLIST）」。Redis 用极少的代码量实现了一套自适应的存储引擎，这也是它能在各种场景下同时兼顾内存和性能的原因。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一个容易混淆的词：「有序」&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Redis 的 List 和 ZSet 都被描述为&quot;有序&quot;，但含义完全不同：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;List 的「有序」= 按插入顺序&lt;/strong&gt;。你从左边推就在左边，右边推就在右边，listpack 中 &lt;code&gt;[ele1, ele2, ele3]&lt;/code&gt; 就是先后插入的次序。两端 push/pop 直接操作头尾，不需要比较内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ZSet 的「有序」= 按 score 数值排序&lt;/strong&gt;。每个元素带一个 score（&lt;code&gt;double&lt;/code&gt; 类型），元素之间比大小，按升序排列。插入时要找到正确的 score 位置塞进去。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;前者是&quot;先后有序&quot;，后者是&quot;大小有序&quot;。解决的是两类完全不同的问题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;五、Redis网络模型&lt;/h1&gt;
&lt;h2&gt;1. Linux五种阻塞模型&lt;/h2&gt;
&lt;h3&gt;(1) 阻塞IO&lt;/h3&gt;
&lt;p&gt;用户进程调用 &lt;code&gt;recvfrom&lt;/code&gt; 后，内核等待数据到达，数据到达后内核将数据从内核空间拷贝到用户空间，整个过程用户线程一直处于阻塞状态（让出 CPU，被内核挂起），两个阶段全部阻塞。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-18.RwPCxV2n.png&amp;amp;w=876&amp;amp;h=432&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(2) 非阻塞IO&lt;/h3&gt;
&lt;p&gt;用户进程反复调用 &lt;code&gt;recvfrom&lt;/code&gt;，如果没有数据，内核立即返回错误码（&lt;code&gt;EAGAIN&lt;/code&gt;），不会挂起线程。反复轮询虽然让第一阶段不再阻塞，但如果写成忙等会严重浪费 CPU。数据到达后，第二阶段拷贝数据时线程依然是阻塞的。忙等空转就是它不如 IO 多路复用的根本原因。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-19.DyPiHE5M.png&amp;amp;w=850&amp;amp;h=424&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(3) IO多路复用&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;FD&lt;/strong&gt;（File Descriptor，文件描述符）是 Linux 中一切 I/O 操作的句柄——每个 socket 连接对应一个 fd。IO 多路复用的核心思想是：&lt;strong&gt;单个线程同时监听多个 fd，哪个先就绪就先处理哪个，避免对每个 fd 的无意义等待。&lt;/strong&gt; 线程阻塞在 &lt;code&gt;select&lt;/code&gt;/&lt;code&gt;poll&lt;/code&gt;/&lt;code&gt;epoll&lt;/code&gt; 上，一旦有 fd 可读或可写，内核就唤醒线程，线程再去对就绪的 fd 执行读写。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-20.dZlRSh5f.png&amp;amp;w=852&amp;amp;h=410&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;监听 fd 的方式经历了三代演进：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;select&lt;/strong&gt;：将用户态的 &lt;code&gt;fd_set&lt;/code&gt;（bitmap，默认上限 1024）拷贝进内核，内核遍历全部 fd 检查就绪状态，再拷回用户态，用户再遍历一遍才能找到就绪的 fd。&lt;strong&gt;两次拷贝 + 两次 O(N) 遍历&lt;/strong&gt;，连接数一多性能急剧下降。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-21.Dyk968dz.png&amp;amp;w=556&amp;amp;h=398&amp;amp;f=webp&quot; alt=&quot;select模式&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;poll&lt;/strong&gt; 对 select 做了最直接的改进：用 &lt;code&gt;struct pollfd&lt;/code&gt; 数组替代固定大小的 bitmap，破除了 1024 限制。但内核和用户态依然要做 O(N) 遍历。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;epoll&lt;/strong&gt; 在 Linux 2.6 引入，三个函数分工：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;epoll_create&lt;/code&gt;：创建 epoll 实例，内核分配 eventpoll 对象&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl(ADD/MOD/DEL)&lt;/code&gt;：将 fd 注册进内核的红黑树，O(logN)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt;：阻塞等待，直接从&lt;strong&gt;就绪链表&lt;/strong&gt;上取就绪的 fd，O(1)，只返回有事件的 fd，无需遍历全量&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-22.MrdlNFn9.png&amp;amp;w=554&amp;amp;h=498&amp;amp;f=webp&quot; alt=&quot;epoll模式&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;epoll 的 LT 和 ET 模式&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LT（Level Triggered，水平触发，默认）&lt;/strong&gt;：只要 fd 缓冲区还有数据，下次 &lt;code&gt;epoll_wait&lt;/code&gt; 仍会通知，编程简单不易丢事件。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ET（Edge Triggered，边缘触发）&lt;/strong&gt;：只在 fd 状态从&quot;无数据&quot;变&quot;有数据&quot;的瞬间通知一次，必须配合非阻塞 IO 一次性读完（循环 read 直到返回 &lt;code&gt;EAGAIN&lt;/code&gt;），否则剩余数据可能永远丢失。优点是减少重复通知，高并发下性能更好。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;基于 epoll 模式的 web 服务的基本流程&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-23.Do5kUfnn.png&amp;amp;w=1230&amp;amp;h=514&amp;amp;f=webp&quot; alt=&quot;基于epoll模式的web服务的基本流程&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;socket() → bind() → listen()           // 创建监听 socket
epoll_create()                          // 创建 epoll 实例
epoll_ctl(ADD, listen_fd)               // 注册监听 fd
while (1) {
    n = epoll_wait();                   // 阻塞等待事件
    for (i = 0; i &amp;lt; n; i++) {
        if (fd == listen_fd) {
            conn_fd = accept();         // 新连接
            set_nonblocking(conn_fd);   // 设为非阻塞
            epoll_ctl(ADD, conn_fd);    // 注册新 fd
        } else {
            read(fd);                   // 非阻塞读
            process();
            write(fd);                  // 写回响应
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(4) 信号驱动IO&lt;/h3&gt;
&lt;p&gt;预先注册 &lt;code&gt;SIGIO&lt;/code&gt; 信号处理函数后立即返回，进程不阻塞。数据就绪时内核发送 &lt;code&gt;SIGIO&lt;/code&gt; 信号，进程在信号处理函数中调用 &lt;code&gt;recvfrom&lt;/code&gt; 完成拷贝（第二阶段仍阻塞）。编程复杂度高，多连接下信号排队和竞态难以处理，Redis 未采用。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-24.CDhSvkn5.png&amp;amp;w=766&amp;amp;h=400&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(5) 异步IO&lt;/h3&gt;
&lt;p&gt;调用 &lt;code&gt;aio_read&lt;/code&gt; 后立即返回，&lt;strong&gt;两个阶段均由内核完成&lt;/strong&gt;——数据直接拷贝到用户指定的 buffer，完成后内核通知用户。全程不阻塞。但 Linux 原生 AIO 对 socket 支持有限（主要面向文件 IO），实际应用极少。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-25.B3JZKar1.png&amp;amp;w=888&amp;amp;h=442&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. Redis网络模型&lt;/h2&gt;
&lt;h3&gt;单线程还是多线程？&lt;/h3&gt;
&lt;p&gt;分两个层面回答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心命令处理&lt;/strong&gt;：始终单线程。从 &lt;code&gt;readQueryFromClient&lt;/code&gt; 读命令 → &lt;code&gt;processCommand&lt;/code&gt; 解析执行 → &lt;code&gt;addReply&lt;/code&gt; 写回复，整条链路在主线程的事件循环中串行完成。这意味着不存在两个命令同时修改同一个 key 的竞争问题，所有数据结构无需加锁。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;整个 Redis 进程&lt;/strong&gt;：6.0 起引入了 IO 线程（&lt;code&gt;io-threads&lt;/code&gt; 配置项），专门把网络读写的 CPU 密集部分分摊到多个线程，但命令执行永远在主线程。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;为什么选择单线程？&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;CPU 不是瓶颈&lt;/strong&gt;。Redis 纯内存操作，单个命令执行时间通常是微秒级。瓶颈在内存带宽和网络 I/O，不是 CPU。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;避免锁开销&lt;/strong&gt;。如果多线程并发执行命令，Dict、SkipList 等核心结构都需要加锁，锁竞争的开销可能超过并行收益——毕竟每个命令本身就几微秒，加锁解锁可能也几微秒。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;代码简单可靠&lt;/strong&gt;。单线程意味着没有竞态条件，没有死锁。早期可能只是 antirez 的个人偏好，但事实上这个选择让 Redis 的核心代码保持了极高的可维护性。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;事件循环的核心源码&lt;/h3&gt;
&lt;p&gt;Redis 的网络模型源码分三层：&lt;code&gt;ae&lt;/code&gt; 抽象层 → epoll 实现层 → 上层网络处理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ae.c —— 主循环
void aeMain(aeEventLoop *eventLoop) {
    eventLoop-&amp;gt;stop = 0;
    while (!eventLoop-&amp;gt;stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS |
                                   AE_CALL_BEFORE_SLEEP |
                                   AE_CALL_AFTER_SLEEP);
    }
}

// ae.c —— 单次循环
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
    // 1. beforeSleep: 过期键清理、AOF刷盘、回复写入、IO线程任务分发
    if (eventLoop-&amp;gt;beforesleep &amp;amp;&amp;amp; (flags &amp;amp; AE_CALL_BEFORE_SLEEP))
        eventLoop-&amp;gt;beforesleep(eventLoop);

    // 2. 阻塞等待 fd 就绪（Linux 下 → epoll_wait）
    numevents = aeApiPoll(eventLoop, tvp);

    // 3. afterSleep
    if (eventLoop-&amp;gt;aftersleep &amp;amp;&amp;amp; flags &amp;amp; AE_CALL_AFTER_SLEEP)
        eventLoop-&amp;gt;aftersleep(eventLoop);

    // 4. 逐个处理就绪 fd 的读写回调（→ readQueryFromClient / sendReplyToClient）
    for (j = 0; j &amp;lt; numevents; j++) {
        if (mask &amp;amp; AE_READABLE)  fe-&amp;gt;rfileProc(eventLoop, fd, ...);
        if (mask &amp;amp; AE_WRITABLE) fe-&amp;gt;wfileProc(eventLoop, fd, ...);
    }

    // 5. 处理到期的定时事件（serverCron 等）
    processTimeEvents(eventLoop);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一次事件循环的完整时序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; ┌──────────────────────────────────────────────────────────┐
 │  aeProcessEvents 单次迭代                                 │
 │                                                          │
 │  beforeSleep()        epoll_wait()      处理就绪fd+定时器  │
 │  ┌──────────────┐   ┌──────────┐    ┌──────────────┐    │
 │  │ 过期键清理    │   │ 阻塞等待  │    │ 读客户端命令   │    │
 │  │ AOF 刷盘     │──→│ fd就绪或  │───→│ 执行命令      │    │
 │  │ 回复写入      │   │ 超时     │    │ 写回复        │    │
 │  │ IO线程分发    │   │          │    │ serverCron   │    │
 │  └──────────────┘   └──────────┘    └──────────────┘    │
 │  不阻塞                阻塞            不阻塞              │
 └──────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 启动完毕后在 &lt;code&gt;main()&lt;/code&gt; 最后一行执行 &lt;code&gt;aeMain(server.el)&lt;/code&gt;，进入上述死循环直到进程退出。&lt;/p&gt;
&lt;h3&gt;一条请求的完整生命周期&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;  1. client 连接 → TCP 三次握手
  2. epoll_wait 检测到 listen_fd 可读
  3. acceptTcpHandler → accept() → createClient()
     - 将 conn_fd 设为 O_NONBLOCK
     - 注册 AE_READABLE 回调 → readQueryFromClient
  4. 客户端发送 &quot;GET mykey\r\n&quot;
  5. epoll_wait 检测到 conn_fd 可读
  6. readQueryFromClient():
     - read(fd, buf, 16KB)  ← 非阻塞读
     - 解析 RESP 协议
  7. processCommand():
     - lookupKey() 查 kvstore 找 redisObject
     - 检查过期、权限
     - call() 执行命令处理函数
  8. addReply() → 回复数据写入 client 的 reply buffer
  9. beforeSleep() 中 handleClientsWithPendingWrites():
     - writeToClient() 把 reply buffer 数据写回 socket
     - 如果 socket 缓冲区满没写完，注册 AE_WRITABLE 回调等下次继续写
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Redis 6.0+ 的多线程网络模型&lt;/h3&gt;
&lt;p&gt;IO 线程只做网络读写和协议解析，&lt;strong&gt;不执行命令&lt;/strong&gt;。实现代码在 &lt;code&gt;iothread.c&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// iothread.c —— 每个 IO 线程内部也是独立的 ae 事件循环
void *IOThreadMain(void *ptr) {
    IOThread *t = ptr;
    aeSetBeforeSleepProc(t-&amp;gt;el, IOThreadBeforeSleep);
    aeMain(t-&amp;gt;el);
    return NULL;
}

// iothread.c —— 初始化 IO 线程池
void initThreadedIO(void) {
    if (server.io_threads_num &amp;lt;= 1) return;
    server.io_threads_active = 1;

    for (int i = 1; i &amp;lt; server.io_threads_num; i++) {
        IOThread *t = &amp;amp;IOThreads[i];
        t-&amp;gt;el = aeCreateEventLoop(server.maxclients + CONFIG_FDSET_INCR);
        // 主线程与 IO 线程通过 eventfd 通信
        aeCreateFileEvent(t-&amp;gt;el, getReadEventFd(t-&amp;gt;pending_clients_notifier),
                          AE_READABLE, handleClientsFromMainThread, t);
        pthread_create(&amp;amp;t-&amp;gt;tid, NULL, IOThreadMain, (void *)t);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;架构示意：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-26.CUAOCx1m.png&amp;amp;w=1304&amp;amp;h=508&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;主线程通过 &lt;code&gt;assignClientToIOThread(c)&lt;/code&gt; 把客户端分配给某个 IO 线程来处理读写。IO 线程完成读写和协议解析后，通过 eventfd 通知主线程，主线程在 &lt;code&gt;beforeSleep&lt;/code&gt; 中调用 &lt;code&gt;handleClientsFromIOThread&lt;/code&gt; 取回结果并执行命令。一句话：&lt;strong&gt;命令执行永不并发，网络读写可以并行。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;六、Redis通信协议&lt;/h1&gt;
&lt;h2&gt;1. RESP协议&lt;/h2&gt;
&lt;p&gt;Redis 的客户端和服务端之间使用 &lt;strong&gt;RESP&lt;/strong&gt;（REdis Serialization Protocol）协议通信。它用纯文本形式传输，人类可读，解析简单，但同时足够紧凑。&lt;/p&gt;
&lt;h3&gt;五种基本数据类型（RESP2）&lt;/h3&gt;
&lt;p&gt;RESP 通过每行第一个字节区分数据类型：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;首字节&lt;/th&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;格式&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simple String&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+内容\r\n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+OK\r\n&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Error&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-错误信息\r\n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-ERR unknown command\r\n&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Integer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:数字\r\n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:1000\r\n&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk String&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$字节数\r\n内容\r\n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$5\r\nhello\r\n&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Array&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*元素个数\r\n各元素...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*2\r\n$3\r\nGET\r\n$5\r\nmykey\r\n&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;RESP3（Redis 6 引入）扩展了 &lt;code&gt;~&lt;/code&gt;（Set）、&lt;code&gt;%&lt;/code&gt;（Map）、&lt;code&gt;#&lt;/code&gt;（Bool）、&lt;code&gt;,&lt;/code&gt;（Double）、&lt;code&gt;(&lt;/code&gt;（Big Number）、&lt;code&gt;=&lt;/code&gt;（Verbatim String）、&lt;code&gt;|&lt;/code&gt;（Attribute）、&lt;code&gt;_&lt;/code&gt;（Null）等类型，但核心思想不变：&lt;strong&gt;首字节决定类型，&lt;code&gt;\r\n&lt;/code&gt; 分隔&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;请求与响应示例&lt;/h3&gt;
&lt;p&gt;客户端发送一个 &lt;code&gt;SET&lt;/code&gt; 命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*3\r\n            ← 数组，共 3 个元素
$3\r\n            ← 第 1 个元素，3 字节
SET\r\n           ← &quot;SET&quot;
$5\r\n            ← 第 2 个元素，5 字节
mykey\r\n         ← &quot;mykey&quot;
$5\r\n            ← 第 3 个元素，5 字节
Hello\r\n         ← &quot;Hello&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务端返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+OK\r\n           ← 简单字符串，表示成功
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个 &lt;code&gt;GET&lt;/code&gt; 命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端：*2\r\n$3\r\nGET\r\n$5\r\nmykey\r\n
服务端：$5\r\nHello\r\n       ← 批量字符串，5 字节 &quot;Hello&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;RESP 在源码中的处理&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;resp_parser.h&lt;/code&gt; 中定义了完整的解析器回调。每种数据类型对应一个回调函数：&lt;code&gt;simple_str_callback&lt;/code&gt;（&lt;code&gt;+&lt;/code&gt;）、&lt;code&gt;error_callback&lt;/code&gt;（&lt;code&gt;-&lt;/code&gt;）、&lt;code&gt;long_callback&lt;/code&gt;（&lt;code&gt;:&lt;/code&gt;）、&lt;code&gt;bulk_string_callback&lt;/code&gt;（&lt;code&gt;$&lt;/code&gt;）、&lt;code&gt;array_callback&lt;/code&gt;（&lt;code&gt;*&lt;/code&gt;），解析完成后由上层 &lt;code&gt;processCommand&lt;/code&gt; 取出 &lt;code&gt;argc&lt;/code&gt;/&lt;code&gt;argv&lt;/code&gt; 执行对应命令。&lt;/p&gt;
&lt;h1&gt;七、Redis内存策略&lt;/h1&gt;
&lt;p&gt;Redis 的内存管理涉及两个层面：键的&lt;strong&gt;过期删除&lt;/strong&gt;（主动的还是人为设的 TTL）和内存满时的&lt;strong&gt;淘汰驱逐&lt;/strong&gt;（不要和过期混淆）。两者分别回答&quot;怎么删到期 key&quot;和&quot;满了怎么办&quot;。&lt;/p&gt;
&lt;h2&gt;1. 过期策略&lt;/h2&gt;
&lt;p&gt;Redis 的键可以设置 TTL（Time To Live），到期后需要被删除。如果只用一个单一的删除机制，要么太慢（累积太多过期 key），要么太耗 CPU（不断扫描）。所以 Redis 采用了&lt;strong&gt;惰性删除 + 定期删除&lt;/strong&gt;的组合策略。&lt;/p&gt;
&lt;h3&gt;惰性删除（Lazy Expiration）&lt;/h3&gt;
&lt;p&gt;每次访问一个 key 时，先检查它是否过期。如果已过期，当场删除并返回空。核心函数是 &lt;code&gt;expireIfNeeded()&lt;/code&gt;，在 &lt;code&gt;lookupKey()&lt;/code&gt; 中被调用。&lt;/p&gt;
&lt;p&gt;优点是不浪费额外 CPU，缺点是如果某个过期 key 再也没被访问，它就永远占着内存。&lt;/p&gt;
&lt;h3&gt;定期删除（Active Expiration）&lt;/h3&gt;
&lt;p&gt;为了解决惰性删除的&quot;垃圾堆积&quot;问题，Redis 周期性主动扫描过期键，分两种模式：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SLOW 模式&lt;/strong&gt;（慢速周期）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;由 &lt;code&gt;serverCron&lt;/code&gt; 周期性触发（&lt;code&gt;hz&lt;/code&gt; 频率，默认每秒 10 次）&lt;/li&gt;
&lt;li&gt;每次从若干个数据库中随机采样，检查并删除过期 key&lt;/li&gt;
&lt;li&gt;单次执行时间有上限：&lt;code&gt;ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC&lt;/code&gt;（默认 25%）的 CPU 时间&lt;/li&gt;
&lt;li&gt;如果一轮没扫完所有数据库，下次继续从未完成的数据库开始&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;FAST 模式&lt;/strong&gt;（快速周期）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;由 &lt;code&gt;beforeSleep&lt;/code&gt; 触发，在事件循环的每次迭代中执行（频率远高于 SLOW）&lt;/li&gt;
&lt;li&gt;单次执行时间上限仅 1ms（&lt;code&gt;ACTIVE_EXPIRE_CYCLE_FAST_DURATION&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;但如果上次 SLOW 周期没有因为超时而退出（说明过期 key 不多），或者过期比例低于阈值，FAST 模式会直接跳过&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两种模式协作的逻辑（&lt;code&gt;expire.c&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  FAST mode（高频短跑）     SLOW mode（低频长跑）
  ┌────────────┐           ┌────────────────────┐
  │ 每次事件循环 │           │ 每次 serverCron     │
  │ 最多耗时 1ms │           │ 最多占 25% CPU 时间 │
  │ 过期少时跳过 │           │ 持续扫描所有数据库   │
  └────────────┘           └────────────────────┘
       ↑                          ↑
       └──── 共同保证过期 key 不会堆积 ────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;惰性删除保证「访问时一定删」，定期删除保证「不访问也最终会删」。两者合力，在 CPU 开销和内存回收之间取得平衡。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;2. 淘汰策略&lt;/h2&gt;
&lt;p&gt;淘汰（Eviction）和过期是两回事：过期是 key 到了 TTL 被删，淘汰是&lt;strong&gt;内存满了不得不删&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;当 Redis 使用的内存达到 &lt;code&gt;maxmemory&lt;/code&gt; 上限时，触发淘汰。策略由 &lt;code&gt;maxmemory-policy&lt;/code&gt; 配置，共 10 种：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;分类&lt;/th&gt;
&lt;th&gt;策略&lt;/th&gt;
&lt;th&gt;选择范围&lt;/th&gt;
&lt;th&gt;淘汰标准&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;不淘汰&lt;/td&gt;
&lt;td&gt;&lt;code&gt;noeviction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;写操作直接报错&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LRU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;volatile-lru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅有过期时间的 key&lt;/td&gt;
&lt;td&gt;近似 LRU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LRU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allkeys-lru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有 key&lt;/td&gt;
&lt;td&gt;近似 LRU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LFU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;volatile-lfu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅有过期时间的 key&lt;/td&gt;
&lt;td&gt;近似 LFU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LFU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allkeys-lfu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有 key&lt;/td&gt;
&lt;td&gt;近似 LFU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;随机&lt;/td&gt;
&lt;td&gt;&lt;code&gt;volatile-random&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅有过期时间的 key&lt;/td&gt;
&lt;td&gt;随机&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;随机&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allkeys-random&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有 key&lt;/td&gt;
&lt;td&gt;随机&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;volatile-ttl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅有过期时间的 key&lt;/td&gt;
&lt;td&gt;最接近过期&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LRM&lt;/td&gt;
&lt;td&gt;&lt;code&gt;volatile-lrm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅有过期时间的 key&lt;/td&gt;
&lt;td&gt;LRU + 采样后二次筛选&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LRM&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allkeys-lrm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有 key&lt;/td&gt;
&lt;td&gt;LRU + 采样后二次筛选&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-27.D6pVznx4.png&amp;amp;w=1656&amp;amp;h=864&amp;amp;f=webp&quot; alt=&quot;淘汰策略&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;近似 LRU/LFU 的具体实现在 &lt;code&gt;redisObject&lt;/code&gt; 的 &lt;code&gt;lru&lt;/code&gt; 字段（24 位时钟/计数器）和 &lt;code&gt;evict.c&lt;/code&gt; 的采样淘汰机制中已有详细说明，见第三章「lru 字段 —— 内存淘汰的近似 LRU」一节。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;八、持久化&lt;/h1&gt;
&lt;p&gt;Redis 是内存数据库，数据在断电后会丢失。持久化就是把内存中的数据保存到磁盘上，重启时再加载回来。Redis 提供了两种机制：&lt;strong&gt;RDB&lt;/strong&gt;（快照）和 &lt;strong&gt;AOF&lt;/strong&gt;（日志），二者可单独使用也可组合使用。&lt;/p&gt;
&lt;h2&gt;1. RDB（Redis Database）&lt;/h2&gt;
&lt;p&gt;RDB 是&lt;strong&gt;全量快照式持久化&lt;/strong&gt;。在某个时间点，把内存中所有数据序列化成一个二进制文件（默认 &lt;code&gt;dump.rdb&lt;/code&gt;），恢复时直接读取这个文件重建整个数据集。&lt;/p&gt;
&lt;h3&gt;触发方式&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;手动触发&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SAVE&lt;/code&gt;：由主线程执行保存，保存期间 Redis 不能处理任何请求，适合停机维护场景。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BGSAVE&lt;/code&gt;：&lt;code&gt;fork()&lt;/code&gt; 出一个子进程，子进程负责写入 RDB 文件，主进程继续处理请求，不阻塞服务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;自动触发&lt;/strong&gt;（通过配置 &lt;code&gt;save&lt;/code&gt; 参数）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;save 900 1     ← 900 秒内至少 1 次修改
save 300 10    ← 300 秒内至少 10 次修改
save 60 10000  ← 60 秒内至少 10000 次修改
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;满足任一条件即自动触发 &lt;code&gt;BGSAVE&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;BGSAVE 的 fork 机制&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;  主进程                     子进程
  ┌────────┐               ┌──────────┐
  │ 处理请求 │  ──fork()──→  │ 遍历内存   │
  │ 继续干活 │              │ 写入RDB文件│
  └────────┘               └──────────┘
       ↑
  共享同一份内存页（Copy-on-Write）
  主进程写某个页时，内核才复制那一页
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;得益于 Linux 的 &lt;strong&gt;Copy-on-Write&lt;/strong&gt;，fork 时并不立即复制全部内存，主进程和子进程共享同一份物理内存页。只有当主进程修改了某个页时，内核才会把该页复制给子进程。因此内存占用峰值 ≈ 原始数据 + 写入期间的增量修改。&lt;/p&gt;
&lt;h3&gt;RDB 的优缺点&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;优点&lt;/th&gt;
&lt;th&gt;缺点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;文件紧凑，适合灾备和迁移&lt;/td&gt;
&lt;td&gt;两次快照之间的数据可能丢失&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;恢复速度快，直接加载二进制&lt;/td&gt;
&lt;td&gt;大数据量下 fork 耗时长，可能造成短暂卡顿&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fork 子进程写入，主进程不阻塞&lt;/td&gt;
&lt;td&gt;频繁 fork 会消耗 CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;2. AOF（Append Only File）&lt;/h2&gt;
&lt;p&gt;AOF 是&lt;strong&gt;增量日志式持久化&lt;/strong&gt;。把每一条修改命令以 RESP 协议格式追加写入日志文件，重启时逐条回放命令来还原数据。&lt;/p&gt;
&lt;h3&gt;刷盘策略&lt;/h3&gt;
&lt;p&gt;核心配置是 &lt;code&gt;appendfsync&lt;/code&gt;，控制 &lt;code&gt;write()&lt;/code&gt; 之后何时执行 &lt;code&gt;fsync()&lt;/code&gt;（真正落盘）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;th&gt;行为&lt;/th&gt;
&lt;th&gt;安全性&lt;/th&gt;
&lt;th&gt;性能&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;always&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每执行一条修改命令立即 fsync&lt;/td&gt;
&lt;td&gt;最高，最多丢一条&lt;/td&gt;
&lt;td&gt;最慢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;everysec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每秒 fsync 一次（默认）&lt;/td&gt;
&lt;td&gt;最多丢 1 秒数据&lt;/td&gt;
&lt;td&gt;折中，生产环境首选&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不主动 fsync，交给 OS 决定&lt;/td&gt;
&lt;td&gt;可能丢几秒数据&lt;/td&gt;
&lt;td&gt;最快，但不推荐&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;源码 &lt;code&gt;flushAppendOnlyFile()&lt;/code&gt; 中做了区分：&lt;code&gt;AOF_FSYNC_ALWAYS&lt;/code&gt; 每次都同步，&lt;code&gt;AOF_FSYNC_EVERYSEC&lt;/code&gt; 靠后台 &lt;code&gt;bio&lt;/code&gt; 线程每秒刷盘。&lt;/p&gt;
&lt;h3&gt;AOF 重写（Rewrite）&lt;/h3&gt;
&lt;p&gt;AOF 文件会随运行时间不断膨胀。例如一个计数器被 INCR 了 100 次，AOF 中有 100 条记录，但最终值其实只需要一条 &lt;code&gt;SET counter 100&lt;/code&gt;。重写就是创建一个新的 AOF 文件，用&lt;strong&gt;最少命令集&lt;/strong&gt;描述当前数据库状态。&lt;/p&gt;
&lt;p&gt;触发条件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;auto-aof-rewrite-percentage 100   ← 文件大小比上次重写后增长 100%
auto-aof-rewrite-min-size 64mb    ← 文件至少 64MB 才考虑重写
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重写同样通过 fork 子进程执行（&lt;code&gt;rewriteAppendOnlyFileBackground()&lt;/code&gt;），不影响主进程对外服务。重写期间新产生的修改命令同时写入旧的 AOF 文件和 AOF 重写缓冲区，等子进程完成后，再把缓冲区内容追加到新文件末尾，原子性地切换（rename）过去。&lt;/p&gt;
&lt;h3&gt;AOF 的优缺点&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;优点&lt;/th&gt;
&lt;th&gt;缺点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;最多丢失 1 秒数据&lt;/td&gt;
&lt;td&gt;文件体积比 RDB 大&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件是 RESP 文本，可读可编辑&lt;/td&gt;
&lt;td&gt;恢复速度比 RDB 慢（逐条回放）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rewrite 机制控制文件膨胀&lt;/td&gt;
&lt;td&gt;&lt;code&gt;always&lt;/code&gt; 策略下性能开销大&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;3. RDB + AOF 混合使用&lt;/h2&gt;
&lt;p&gt;Redis 4.0 引入了混合模式（&lt;code&gt;aof-use-rdb-preamble yes&lt;/code&gt;）。AOF 重写时，子进程先把当前数据以 RDB 格式写入 AOF 文件头，再把后续的增量命令以 AOF 格式追加。结果是：&lt;strong&gt;前半段的 RDB 部分加载快，后半段的 AOF 部分保证数据完整性&lt;/strong&gt;。生产环境推荐开启。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;混合 AOF 文件结构:
 ┌──────────────┬────────────────────────┐
 │ RDB 格式快照  │ AOF 增量命令（RESP文本） │
 │  (二进制)     │  重写之后的修改命令      │
 └──────────────┴────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;九、事务&lt;/h1&gt;
&lt;h2&gt;1. 事务的实现&lt;/h2&gt;
&lt;p&gt;Redis 的事务和关系型数据库的事务&lt;strong&gt;完全不同&lt;/strong&gt;——没有隔离级别，没有回滚，不做行锁。它的语义很简单：&lt;strong&gt;把一组命令打包，按顺序串行执行，执行期间不插入其他客户端的命令&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;三个核心命令：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MULTI&lt;/code&gt;：开启事务，标记客户端进入事务状态（&lt;code&gt;CLIENT_MULTI&lt;/code&gt; 标志位）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EXEC&lt;/code&gt;：执行队列中所有命令&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DISCARD&lt;/code&gt;：放弃事务，清空队列&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;源码流程（&lt;code&gt;multi.c&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  MULTI → 客户端设置 CLIENT_MULTI 标志
  之后的每个命令 → 不执行，而是加入 c-&amp;gt;mstate.commands 队列
  每次收到命令 → addReply(c, shared.queued)  告诉客户端&quot;已排队&quot;
  EXEC → 遍历队列，逐个 call() 执行
  DISCARD → 清空队列，取消 CLIENT_MULTI 标志
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;// multi.c —— 事务中的命令不执行，只排队
void queueMultiCommand(client *c, uint64_t cmd_flags) {
    multiCmd *mc;
    // ...
    mc = &amp;amp;c-&amp;gt;mstate.commands[c-&amp;gt;mstate.count];
    mc-&amp;gt;cmd = c-&amp;gt;cmd;
    mc-&amp;gt;argv_len = c-&amp;gt;argv_len;
    // 复制参数、记录命令 —— 但不执行
    c-&amp;gt;mstate.count++;
}

// multi.c —— EXEC 时批量执行
void execCommand(client *c) {
    // 检查是否被 WATCH 破坏
    if (c-&amp;gt;flags &amp;amp; (CLIENT_DIRTY_CAS | CLIENT_DIRTY_EXEC)) {
        discardTransaction(c);
        return;  // 事务中止
    }
    // 逐条执行队列中的命令
    for (j = 0; j &amp;lt; c-&amp;gt;mstate.count; j++) {
        call(c, c-&amp;gt;mstate.commands[j].cmd);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 原子性的真实含义&lt;/h2&gt;
&lt;p&gt;Redis 事务的原子性，指的是&lt;strong&gt;执行期间不被打断&lt;/strong&gt;——&lt;code&gt;MULTI&lt;/code&gt; 和 &lt;code&gt;EXEC&lt;/code&gt; 之间排队的命令会一口气全部执行完，不会插入其他客户端的命令。但它&lt;strong&gt;不保证事务中某条命令失败后回滚前面的成功命令&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;举例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MULTI
SET key1 &quot;a&quot;
INCR key1       ← 对字符串执行 INCR，会失败（类型错误）
SET key2 &quot;b&quot;
EXEC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果：&lt;code&gt;key1&lt;/code&gt; 被设为 &lt;code&gt;&quot;a&quot;&lt;/code&gt;，&lt;code&gt;INCR key1&lt;/code&gt; 报错，但 &lt;code&gt;key2&lt;/code&gt; 仍然被设为 &lt;code&gt;&quot;b&quot;&lt;/code&gt;。前面的 &lt;code&gt;SET key1 &quot;a&quot;&lt;/code&gt; 不会因为后面 &lt;code&gt;INCR&lt;/code&gt; 失败而回滚。Redis 的设计哲学是：编程错误应该在开发阶段暴露，不应该依赖生产环境的运行时回滚。&lt;/p&gt;
&lt;h2&gt;3. WATCH —— 乐观锁&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;WATCH key&lt;/code&gt; 可以监视一个 key。如果在 &lt;code&gt;WATCH&lt;/code&gt; 之后、&lt;code&gt;EXEC&lt;/code&gt; 之前，被监视的 key 被其他客户端修改了，整个事务会被中止（返回 &lt;code&gt;nil&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;这是 Redis 的&lt;strong&gt;乐观锁&lt;/strong&gt;机制：不阻止别人写，但在提交时检查版本是否变了。常用于实现原子性的&quot;检查后操作&quot;（比如余额扣减前检查余额是否足够）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WATCH balance
GET balance        ← 假设返回 100
MULTI
DECRBY balance 50
EXEC              ← 如果 balance 在 WATCH 后被别人改了，这里返回 nil
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;源码中通过 &lt;code&gt;c-&amp;gt;watched_keys&lt;/code&gt; 列表跟踪，被监视的 key 被修改时会设置 &lt;code&gt;CLIENT_DIRTY_CAS&lt;/code&gt; 标志位，&lt;code&gt;EXEC&lt;/code&gt; 检测到该标志位后中止执行。&lt;/p&gt;
&lt;h1&gt;十、主从复制&lt;/h1&gt;
&lt;h2&gt;1. 复制的意义&lt;/h2&gt;
&lt;p&gt;单机 Redis 有 QPS 上限，且存在单点故障风险。主从复制的核心目标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;读写分离&lt;/strong&gt;：主节点处理写请求，多个从节点处理读请求，分摊读压力&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据冗余&lt;/strong&gt;：从节点持有完整数据副本，主节点宕机后可接管&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高可用基础&lt;/strong&gt;：配合哨兵（Sentinel），实现自动故障转移&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 复制流程&lt;/h2&gt;
&lt;p&gt;Redis 的复制分为&lt;strong&gt;全量同步&lt;/strong&gt;和&lt;strong&gt;部分同步&lt;/strong&gt;两个阶段。&lt;/p&gt;
&lt;h3&gt;全量同步（Full Resynchronization）&lt;/h3&gt;
&lt;p&gt;初次连接或复制信息丢失时触发，流程如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  Slave                          Master
   │                                │
   │──── PSYNC ? -1 ──────────────→│   (首次连接，不知道任何 offset)
   │                                │
   │←── FULLRESYNC &amp;lt;replid&amp;gt; &amp;lt;offset&amp;gt;│   告诉 Slave &quot;我们要全量同步&quot;
   │                                │
   │                                │   Master 执行 BGSAVE，生成 RDB
   │                                │   ︙
   │←── 发送 RDB 文件 ─────────────→│
   │                                │
   │  加载 RDB，重建数据              │
   │                                │
   │←── 发送 RDB 期间的增量命令 ─────│   (replication buffer)
   │                                │
   │  执行增量命令，追上最新状态       │
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Slave 发送 &lt;code&gt;PSYNC ? -1&lt;/code&gt;（&lt;code&gt;?&lt;/code&gt; 表示未知 master，&lt;code&gt;-1&lt;/code&gt; 表示没有 offset）&lt;/li&gt;
&lt;li&gt;Master 返回 &lt;code&gt;FULLRESYNC&lt;/code&gt; 和自己的 replid + offset&lt;/li&gt;
&lt;li&gt;Master 执行 &lt;code&gt;BGSAVE&lt;/code&gt; 生成 RDB，发送给 Slave&lt;/li&gt;
&lt;li&gt;RDB 生成期间的写操作暂存在 replication buffer 中，RDB 发完后一并发送&lt;/li&gt;
&lt;li&gt;Slave 先加载 RDB，再执行增量命令，之后进入命令传播阶段&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;部分同步（Partial Resynchronization）&lt;/h3&gt;
&lt;p&gt;在连接短暂断开后重连时，如果条件满足，可以避免全量同步：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  Slave                          Master
   │                                │
   │── PSYNC &amp;lt;replid&amp;gt; &amp;lt;offset&amp;gt; ──→│
   │                                │
   │                             检查 repl_backlog 中是否还有 offset 位置的数据
   │                                │
   │←── CONTINUE ────────────────│   (在 backlog 中找到了，只补发差额)
   │                                │
   │←── 发送 offset 之后的增量 ────│
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部分同步依赖 Master 的 &lt;strong&gt;replication backlog&lt;/strong&gt;（默认 1MB 的环形缓冲区）。Master 会将每个写操作同时写入 backlog。如果 Slave 携带的 offset 仍然在 backlog 范围内，就可以只补发差额；如果已经超出范围（Slave 断开太久，backlog 中对应位置的数据被覆盖了），则降级为全量同步。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// replication.c —— backlog 定义
server.repl_backlog-&amp;gt;offset = server.master_repl_offset + 1;
server.repl_backlog-&amp;gt;histlen = 0;  // 当前 backlog 存储的数据长度
// 当 histlen &amp;gt; repl_backlog_size 时，旧数据被覆盖
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;命令传播&lt;/h3&gt;
&lt;p&gt;全量或部分同步完成后，Master 和 Slave 进入稳定状态。Master 每执行一个写命令，都会把命令广播给所有 Slave。Slave 接收后执行，保持数据一致。这是一个&lt;strong&gt;异步&lt;/strong&gt;过程——Master 不等待 Slave 确认就返回客户端，因此主从之间可能存在毫秒级的复制延迟。&lt;/p&gt;
&lt;h2&gt;3. 主从拓扑&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;         ┌─────────┐
         │  Master  │  ← 写请求
         └────┬────┘
      ┌───────┼───────┐
      ↓       ↓       ↓
  ┌──────┐ ┌──────┐ ┌──────┐
  │Slave1│ │Slave2│ │Slave3│  ← 读请求
  └──────┘ └──────┘ └──────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Slave 也可以有自己的 Slave，形成级联复制，减轻 Master 的复制压力。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;全文总结&lt;/h2&gt;
&lt;p&gt;十个章节串起 Redis 从数据到网络、从存储到容灾的完整知识体系：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一、背景        —— Redis 是什么、为什么快
二、组件        —— SDS / IntSet / Dict / ZipList / QuickList / SkipList / Listpack
                  七种积木块，解决「数据怎么存」
三、RedisObject —— 类型系统，encoding 字段把五种类型映射到七种组件
四、五种数据结构 —— String / List / Set / ZSet / Hash，对外暴露的最终形态
五、网络模型     —— epoll + 事件循环 + IO 线程，解决「请求怎么来、回复怎么回」
六、通信协议     —— RESP 协议，客户端和服务端的通用语言
七、内存策略     —— 过期删除 + 淘汰驱逐，解决「内存不够怎么办」
八、持久化       —— RDB 快照 + AOF 日志，解决「数据丢了怎么办」
九、事务         —— MULTI/EXEC/WATCH，一组命令原子执行，不被打断
十、主从复制     —— 全量同步 + 部分同步 + 命令传播，解决「单机不够怎么办」
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>PyTorch 学习路线图：从张量到 Transformer</title><link>https://owen571.top/posts/study/pytorch/00-pytorch-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/00-pytorch-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>把三套不同来源的 PyTorch 笔记和代码重新整理成一条循序渐进的学习路线，先建立训练心智，再进入 CNN、RNN 和手写 Transformer。</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这组文章整理自我手头三份不同来源的 PyTorch 资料：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;liuer_pytorch&lt;/code&gt;：跟着一套完整课程从头做到尾，主线比较完整。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pytorch_learning&lt;/code&gt;：我自己之前断断续续做过的练手笔记，更偏 API 和记忆点。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pytorch_using&lt;/code&gt;：一份单独手写 Transformer 的实践代码。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果直接按原文件夹去看，会有两个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;同一个主题被分散在不同目录里，学习节奏容易断。&lt;/li&gt;
&lt;li&gt;有些内容偏“随手查 API”，有些内容偏“课程式推进”，混在一起不太像一条能连续读的路线。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以我把它们重排成了下面这条主线：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;线性回归、梯度下降与训练四步&lt;/code&gt;
先用最简单的回归任务把训练流程摸清楚：数据、模型、损失、优化器。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Tensor、Autograd 与动态计算图&lt;/code&gt;
把 PyTorch 和 NumPy 拉开差距的关键，就在 Tensor 和自动微分。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;分类任务、Dataset / DataLoader 与训练循环&lt;/code&gt;
从二分类、多分类到小作业，真正把“如何喂数据、如何训练一个分类模型”串起来。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Module、functional、optim 工具箱&lt;/code&gt;
这一篇专门整理容易散落的 API：&lt;code&gt;nn.Module&lt;/code&gt;、&lt;code&gt;nn.functional&lt;/code&gt;、优化器、初始化和工程辅助工具。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;CNN：从 LeNet 到经典卷积网络&lt;/code&gt;
把卷积、池化、LeNet、GoogLeNet、ResNet 这些卷积神经网络的核心脉络拉成一条线。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RNN 与序列建模入门&lt;/code&gt;
从 one-hot、embedding、RNN / LSTM 到名字-国家分类，把序列模型的最小心智先搭起来。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;手写 Transformer 实现拆解&lt;/code&gt;
最后回到一个真正的 PyTorch 代码实践：不用 &lt;code&gt;nn.Transformer&lt;/code&gt;，自己把位置编码、多头注意力、Encoder / Decoder 组起来。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样整理之后，这组文章的阅读顺序就不是“看到什么学什么”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先明白训练一个模型到底在做什么&lt;/li&gt;
&lt;li&gt;再理解 PyTorch 提供了哪些关键抽象&lt;/li&gt;
&lt;li&gt;然后进入具体网络结构&lt;/li&gt;
&lt;li&gt;最后用 Transformer 做一次综合收束&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;正文里我尽量保留了原始笔记的内容、写法和代码，只做了这几类整理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;合并重复主题&lt;/li&gt;
&lt;li&gt;补上过渡说明，让章节之间更好衔接&lt;/li&gt;
&lt;li&gt;把零散的 API 速记收成更适合复习的结构&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你也是第一次系统整理 PyTorch，建议按这里的顺序往下读。&lt;/p&gt;
</content:encoded></item><item><title>强化学习学习路线图：从 RL 基础到对齐训练</title><link>https://owen571.top/posts/study/reinforce-learning/00-%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/00-%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>把当前这组强化学习笔记收成一条更适合系统学习的路径，从 MDP、DQN、策略梯度一路走到 RLHF、DPO 与 RLVR。</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这组文章来自我阶段性整理强化学习相关笔记的结果。最开始的记录更像“边学边写”，主题会随着理解推进不断外扩：先从 RL 基础进入，再走到 DQN、策略梯度、Actor-Critic，最后自然连接到 LLM 对齐里的 RLHF、DPO 和 RLVR。&lt;/p&gt;
&lt;p&gt;所以这次我没有按零散知识点保留原顺序，而是按一条更适合学习的主线把它们重新排成系列：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Frl-overview.Bv3g-QEQ.jpg&amp;amp;w=2400&amp;amp;h=1569&amp;amp;f=webp&quot; alt=&quot;强化学习总览&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;强化学习入门：为什么需要 RL、术语与 MDP&lt;/code&gt;
先建立为什么要用 RL、RL 在解决什么，以及 MRP / MDP / Bellman 这些最基础的心智模型。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;免模型强化学习：DP、MC、TD、SARSA 与 Q-learning&lt;/code&gt;
当环境模型未知时，如何从“建模”转向“基于经验学习”，这是后面所有现代算法的真正起点。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;从表格到函数：DQN 与 Value-Based 深度强化学习&lt;/code&gt;
把表格型 Q 函数推进到深度网络近似，并把 DDQN、PER 等常见改进放到一条线上看。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;策略梯度入门：从定理到 REINFORCE&lt;/code&gt;
从 value-based 切到 policy-based，理解为什么“直接学策略”是必要的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Actor-Critic 主线：优势函数、GAE、TRPO 与 PPO&lt;/code&gt;
这是现代强化学习最常见的一条工程主线，也是后面 RLHF 会不断回来的基础。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;LLM 对齐训练：RLHF、奖励模型与规则化分支&lt;/code&gt;
把强化学习真正接到大模型对齐问题上，开始进入 reward model、PPO、Constitutional AI 这些核心概念。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Off-Policy 偏好优化：DPO 与新分支&lt;/code&gt;
在 RLHF 主线之外，补上 DPO 这条更轻量、也更常见的偏好优化路线。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;可验证强化学习：RLVR 与 Tülu 3&lt;/code&gt;
当奖励可以被规则直接验证时，强化学习又会呈现出怎样的新训练形态。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RLHF 奠基论文：Helpful &amp;amp; Harmless Assistant 速记&lt;/code&gt;
作为补充阅读，把早期奠基工作单独抽出来，方便后面回看。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这组文章正文尽量保留了原始笔记，只做了三类整理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;调整顺序与命名，让它更像一条学习路径&lt;/li&gt;
&lt;li&gt;补 frontmatter、导读和站内图片资源&lt;/li&gt;
&lt;li&gt;对少量明显的占位标题或断裂处做轻度修正&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你是第一次系统啃强化学习，我建议就按这里的顺序往下读。前半段先把基础与算法骨架搭起来，后半段再回到 LLM 对齐和偏好优化，整体会顺很多。&lt;/p&gt;
</content:encoded></item><item><title>RAG 学习路线图：从基础管线到进阶检索与评估</title><link>https://owen571.top/posts/study/rag/00-rag-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/00-rag-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>把当前 1 到 13 篇 RAG 笔记重排成一条更适合学习的路径：先搭基础管线，再进入检索优化、查询优化与评估。</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这组文章来自我当前阶段对 RAG 的连续学习记录。和只查某个 API 或某个库的文档不同，RAG 更像一条系统链路：从数据进入，到分块、嵌入、索引、检索、查询优化，再到评估与迭代，环节之间的因果关系很强。&lt;/p&gt;
&lt;p&gt;所以这一组我没有按“工具名”来组织，而是按“理解系统”的顺序来收：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 入门：概念、优势与演进路线&lt;/code&gt;
先回答最基础的问题：RAG 到底在解决什么，为什么很多时候它比微调更合适，以及 Naive / Advanced / Modular RAG 的演进路线是什么。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 数据加载：文档解析与预处理入口&lt;/code&gt;
当我们说“把知识接进系统”时，第一步到底在做什么。这里主要看文档加载器、非结构化数据解析，以及为什么加载质量会直接影响后面的检索效果。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 文本分块：为什么切、怎么切、怎么权衡&lt;/code&gt;
分块是 RAG 最容易看轻、但最影响效果的环节之一。这里把块大小、重叠、递归分块、语义分块、结构化分块等策略整理到一起。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 索引基础：向量嵌入、相似度与向量数据库&lt;/code&gt;
从“文本为什么能变成向量”讲起，再进入相似度度量和向量数据库的角色，把检索层的基础心智搭起来。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Milvus 入门：集合、索引与检索流程&lt;/code&gt;
当基础概念清楚之后，再具体进入 Milvus，理解 collection、schema、index、search 等真正搭系统时会用到的对象。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Milvus 多模态实践：图文嵌入到检索闭环&lt;/code&gt;
最后回到一条更接近实战的路径，用多模态样例把“编码 -&amp;gt; 入库 -&amp;gt; 建索引 -&amp;gt; 查询 -&amp;gt; 可视化”串起来。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Naive-RAG 端到端实战&lt;/code&gt;
把前面学过的加载、分块、嵌入、Milvus 检索和 FastAPI 串起来，先做一个最小但完整的 RAG demo，重点关注 grounded answer、评估指标和可部署性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 索引优化：上下文拓展与结构化索引&lt;/code&gt;
开始从“能跑”进入“怎么跑得更合理”。这一篇讨论索引层的两个关键思路：检索粒度与生成粒度不必相同，以及知识库变大后如何借助 metadata 做结构化过滤与路由。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 混合检索：稀疏、密集与 Milvus 实现&lt;/code&gt;
把 dense / sparse 两条检索线放到同一张图里理解，再进入 RRF 与线性加权的差别，以及 Milvus 中如何落地双路召回。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 查询构建：从元数据过滤到 Text2SQL&lt;/code&gt;
当知识源不只是自由文本，查询本身也要升级。这里主要看自然语言如何转成 metadata filter、Cypher 或 SQL。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 查询翻译：重写、分解与路由&lt;/code&gt;
继续往前推进到 query optimization：原始问题未必是最优检索输入，所以要学会重写、拆分、HyDE 和查询路由。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 检索进阶：重排、压缩与校正&lt;/code&gt;
进入生产感更强的一层：初步召回以后，怎么通过 rerank、compression 和 corrective retrieval 控制最终送给模型的上下文质量。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;RAG 评估：指标、工作流与工具&lt;/code&gt;
最后收口到评估：先评检索，再评响应，再谈 RAGAS、Phoenix 等工具。这样系统效果变差时，才知道该改哪一段。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这条路线的目的不是把 RAG 讲成一个“背术语的模块集合”，而是尽量把它还原成一条完整的数据与检索管线。正文我尽量保留了原始笔记，只做了这几类整理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;调整了文章顺序与命名&lt;/li&gt;
&lt;li&gt;补了系列 frontmatter 和少量导读&lt;/li&gt;
&lt;li&gt;统一了图片资源路径，让它能直接进入博客文集&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你是第一次系统啃 RAG，建议就按这里的顺序往下读。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 学习路线图：先组件，后 Agents，再回看 Middleware</title><link>https://owen571.top/posts/study/langchain/00-langchain-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/00-langchain-%E5%AD%A6%E4%B9%A0%E8%B7%AF%E7%BA%BF%E5%9B%BE/</guid><description>把原本偏“查询式”的官方文档重排成一条更适合系统学习的路径，先建立基础心智，再回到 Agents 与 Middleware。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这组文章来自我自己阅读 LangChain 官方文档时做的整理。原始笔记目前已经写到 10 篇，但官方文档的组织方式更像“方便查 API”，不完全像“方便学习一门框架”。&lt;/p&gt;
&lt;p&gt;我读下来的几个不适感，基本就是这三点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;一上来先讲 &lt;code&gt;Agents&lt;/code&gt;，容易让人先看到综合体，再去倒推底层组件。&lt;/li&gt;
&lt;li&gt;文中经常会提前出现尚未展开的概念，查询时很方便，学习时却容易打断节奏。&lt;/li&gt;
&lt;li&gt;某些章节的边界并不稳定，像 &lt;code&gt;Models / Messages / Structured Output / Tools&lt;/code&gt; 之间会互相提前引用。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以我把这条学习线改成了下面这条顺序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;OpenAI API 调用基线&lt;/code&gt;
先建立“模型调用到底发生了什么”的最小心智。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;LangChain 入门与 Quick Start&lt;/code&gt;
先跑通一个最小例子，再看它的设计哲学，知道这个框架想解决什么问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Models&lt;/code&gt;
先理解模型对象本身怎么初始化、怎么调用、怎么流式输出。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Messages&lt;/code&gt;
模型吃进去和吐出来的最核心单位到底是什么。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Tools&lt;/code&gt;
让模型真正开始“做事”，并理解运行时上下文、状态、存储。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Short-term Memory&lt;/code&gt;
当对话变长时，怎么把状态和历史留住，以及如何裁剪、总结。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Streaming&lt;/code&gt;
当模型和 Agent 真的跑起来时，如何把过程实时展示出来。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Structured Output&lt;/code&gt;
当你不想只拿一段自然语言，而是想拿稳定可解析的数据结构时应该怎么做。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Agents&lt;/code&gt;
最后再回到 Agent，把前面的组件重新装回一台能运行的机器里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Middleware&lt;/code&gt;
最后再回头看 middleware。因为它其实是对整个 agent loop 的“运行时切面控制”，不先理解 Agents，很难真正看懂它拦在哪里、为什么好用。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样读的好处是：&lt;br /&gt;
先把“零件”摸透，再去理解“整机”；先懂模型、消息、工具、记忆这些底层块，再回头看 &lt;code&gt;create_agent()&lt;/code&gt;，很多原本觉得跳跃的地方就会顺下来。&lt;/p&gt;
&lt;p&gt;你可以把这组文章当成一条 LangChain 的学习路径，而不是单纯的文档摘抄。正文我尽量保留了原始笔记，只做了三类处理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;调整文章顺序&lt;/li&gt;
&lt;li&gt;补了 frontmatter 和少量导读&lt;/li&gt;
&lt;li&gt;保留原有代码、图和大部分表述，只做轻度润色&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你是第一次系统啃 LangChain，建议就按这里的顺序一路往下读。&lt;/p&gt;
</content:encoded></item><item><title>Study 栏写作说明</title><link>https://owen571.top/posts/study/start-here/</link><guid isPermaLink="true">https://owen571.top/posts/study/start-here/</guid><description>Study 分区会自动扫描一级目录，并把它们接到知识星点与文章筛选里。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Study 现在只读取 &lt;code&gt;src/content/posts/study/&lt;/code&gt; 下的文章，并且会自动扫描它下面的一级目录生成主题入口与筛选项。&lt;/p&gt;
&lt;p&gt;你之后写学习类内容时，只需要按主题新建目录，然后把文章放进去：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Python Base&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/python-base/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LLM Base&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/llm-base/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;算法题&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/算法题/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fine Tuning&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/fine-tuning/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FastAPI&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/fastapi/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Pytorch&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/pytorch/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Reinforce Learning&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/reinforce-learning/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LangChain&lt;/code&gt; -&amp;gt; &lt;code&gt;src/content/posts/study/langchain/&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果你之后想新增别的主题，比如 &lt;code&gt;study/transformer-notes/&lt;/code&gt;，只要新建这个目录，它就会自动出现在 Study 的知识星点和文章筛选里。&lt;/p&gt;
&lt;p&gt;如果你想控制这个目录在 Study 里的标题、右上角小图标、颜色和排序，就在目录里放一个 &lt;code&gt;meta.json&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;title&quot;: &quot;Reinforce Learning&quot;,
  &quot;eyebrow&quot;: &quot;RL&quot;,
  &quot;description&quot;: &quot;放强化学习、DQN、PPO、策略梯度等内容。&quot;,
  &quot;size&quot;: &quot;medium&quot;,
  &quot;accent&quot;: &quot;138 168 255&quot;,
  &quot;icon&quot;: &quot;mdi:robot-outline&quot;,
  &quot;order&quot;: 70
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;目前支持这些标识：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;title&lt;/code&gt;
目录展示标题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;eyebrow&lt;/code&gt;
目录的小标签。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;description&lt;/code&gt;
目录说明文案。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;icon&lt;/code&gt;
目录图标，推荐用 &lt;code&gt;mdi:*&lt;/code&gt;，例如 &lt;code&gt;mdi:robot-outline&lt;/code&gt;、&lt;code&gt;mdi:brain&lt;/code&gt;、&lt;code&gt;mdi:api&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;accent&lt;/code&gt;
目录主题色，格式是 &lt;code&gt;&quot;118 166 255&quot;&lt;/code&gt; 这种 RGB 三元组字符串。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;size&lt;/code&gt;
展示尺寸参数，可选：
&lt;code&gt;&quot;wide&quot;&lt;/code&gt;、&lt;code&gt;&quot;medium&quot;&lt;/code&gt;、&lt;code&gt;&quot;default&quot;&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;order&lt;/code&gt;
目录排序，数字越小越靠前。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;hidden&lt;/code&gt;
是否隐藏这个目录入口，填 &lt;code&gt;true&lt;/code&gt; 后不会显示在 Study 里。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Study 现在会直接联动下方的 &lt;code&gt;Study 全部文章&lt;/code&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;点击知识星点图里的主题节点，或者直接点筛选器&lt;/li&gt;
&lt;li&gt;页面会滚动到下面的总文章列表&lt;/li&gt;
&lt;li&gt;自动筛选出这个目录下的文章&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;写法还是普通 Markdown：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
title: 你的文章标题
published: 2026-03-21
description: 一句话摘要
tags: [Python, Study]
category: Python Base
draft: false
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果你想在文章里插入可运行的 Python 代码块，可以直接这样写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;total = sum(i * i for i in range(6))
print(&quot;sum =&quot;, total)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面这种写法现在会在文章页渲染成可折叠的 Python 代码卡。
如果你想真正边写边跑，可以直接使用页面右侧悬浮的 &lt;code&gt;Python Lab&lt;/code&gt; 小窗口。&lt;/p&gt;
&lt;p&gt;写完保存后，本地开发环境会自动刷新；Study 的知识星点、筛选器和文章列表也会同步更新。提交并部署后，线上站点会一起更新。&lt;/p&gt;
</content:encoded></item><item><title>PDF-RAG-Agent 项目文档</title><link>https://owen571.top/posts/lab/zotero-paper-rag/00-zotero-paper-rag-%E9%A1%B9%E7%9B%AE%E6%96%87%E6%A1%A3/</link><guid isPermaLink="true">https://owen571.top/posts/lab/zotero-paper-rag/00-zotero-paper-rag-%E9%A1%B9%E7%9B%AE%E6%96%87%E6%A1%A3/</guid><description>PDF-RAG-Agent 完整项目文档——基于 Zotero 论文库的智能研究助手，从普通 RAG 演进为可追踪、可校验的论文 Agent。</description><pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;1. 项目介绍&lt;/h2&gt;
&lt;p&gt;PDF-RAG-Agent 是一个面向 Zotero 个人论文库的智能论文研究助手。它基于 FastAPI、SSE 流式对话和可视化前端，将用户问题先解析为结构化意图，再通过会话记忆、本地 PDF 语料检索、必要的 Web 搜索、证据抽取、claim 生成与 grounding 校验，最终输出带引用来源的 Markdown 回答。系统支持 PDF 文本、表格、图像/图注等多模态证据处理，默认使用 Milvus Dense 向量检索（可选 BM25/Title Anchor 多路融合），并在前端实时展示 Intent、Tool Loop、Evidence、Verification 和 PDF 预览，让论文问答从普通 RAG 升级为一个可追踪、可校验、支持多轮研究上下文的论文 Agent。&lt;/p&gt;
&lt;h2&gt;2. 项目背景与目标&lt;/h2&gt;
&lt;h3&gt;2.1 为什么需要这个项目&lt;/h3&gt;
&lt;p&gt;该项目是在学习了RAG技术、Agent知识等内容后，想要做一个实际有价值、能解决固定问题的Agent系统，从而锻炼自己的Agent设计能力，积累相关经验。&lt;/p&gt;
&lt;h3&gt;2.2 普通 PDF RAG 的问题&lt;/h3&gt;
&lt;p&gt;常见的PDF RAG方法，对于大量pdf而言，召回困难，且得到的信息生硬，并且一旦需要多次RAG才能得到答案的问题，完全无法解决。&lt;/p&gt;
&lt;h3&gt;2.3 想解决什么&lt;/h3&gt;
&lt;p&gt;我们的目标是实现一个智能论文研究助手。它不仅要能快速找到目标论文，还要能完成多论文比较、论文公式提取、论文图表理解、用户意图拆解、多轮上下文延续和基础自我认知。也就是说，系统不能只停留在“检索一段文本然后回答”的普通 RAG 形态，而是需要在 RAG 层面做精细设计，并配合一个成熟可用的 Agent 系统，才能完成真实论文研究场景中的复杂问题。&lt;/p&gt;
&lt;h2&gt;3. 系统架构总览&lt;/h2&gt;
&lt;p&gt;PDF-RAG-Agent 是一个围绕论文研究的 Agent Loop 系统。从部署视角看分为前端、API、Agent、检索、数据、模型调用六层。从代码组织看，&lt;code&gt;app/services/&lt;/code&gt; 下按职责分为四组：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app/services/
├── 基础设施
│   └── infra/          model_clients, confidence, prompt_safety
├── 数据与检索
│   ├── library/        core, zotero_sqlite, metadata_sql, citation_ranking
│   ├── retrieval/      DualIndexRetriever, indexing, pdf_extractor, vector_index, web_search
│   └── memory/         session_store, learnings, artifacts, research
├── 领域逻辑（14 个子包）
│   ├── intents/        LLMIntentRouter, research, conversation, library, figure, followup, marker_matching
│   ├── planning/       research plan, query_shaping, query_rewrite, compound_tasks, solver_dispatch
│   ├── contracts/      session_context, normalization, contextual_resolver, followup_relationship
│   ├── claims/         ★ 23 modules: solver_pipeline, 13 deterministic solvers, verifiers, helpers
│   ├── answers/        entity, evidence_presentation, citation_whitelist, followup, formula, paper, topology, library_recommendations
│   ├── entities/       definition_helpers, definition_profiles, type_inference
│   ├── followup/       candidates, relationship_memory
│   ├── clarification/  intents, questions, limit_runtime
│   ├── eval/           judge (LLM-as-judge for evaluation)
│   └── tools/          dynamic_context, proposals, registry_helpers
└── Agent 编排
    ├── agent/          ★ 26 modules: core, loop, planner, runtime, chat_runtime, compound, handlers, traces
    └── agent_mixins/   answer_composer, claim_verifier, entity_definition, followup_routing, solver_pipeline
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;与前几版最大的架构变化：Agent 核心不再是一个巨大的单文件，而是拆成了 &lt;code&gt;agent/&lt;/code&gt;（编排）和 &lt;code&gt;agent_mixins/&lt;/code&gt;（正交能力注入）两层。领域逻辑也不在 Agent 内部耦合——&lt;code&gt;claims/&lt;/code&gt;（23 个模块）、&lt;code&gt;intents/&lt;/code&gt;、&lt;code&gt;planning/&lt;/code&gt;、&lt;code&gt;contracts/&lt;/code&gt;、&lt;code&gt;answers/&lt;/code&gt; 都是独立的领域子包，Agent 通过组合它们完成推理。&lt;/p&gt;
&lt;h3&gt;3.1 前端层&lt;/h3&gt;
&lt;p&gt;前端层由 &lt;code&gt;app/static/index.html&lt;/code&gt; 提供单页页面，包含 Zotero 论文库侧栏、聊天区、运行时 Inspector、引用来源和 PDF 预览区域。用户的问题通过普通聊天接口或 SSE 流式接口发送到 API 层，前端再根据后端返回的 &lt;code&gt;session&lt;/code&gt;、&lt;code&gt;contract&lt;/code&gt;、&lt;code&gt;agent_plan&lt;/code&gt;、&lt;code&gt;plan&lt;/code&gt;、&lt;code&gt;observation&lt;/code&gt;、&lt;code&gt;agent_step&lt;/code&gt;、&lt;code&gt;thinking_delta&lt;/code&gt;、&lt;code&gt;tool_call&lt;/code&gt;、&lt;code&gt;candidate_papers&lt;/code&gt;、&lt;code&gt;screened_papers&lt;/code&gt;、&lt;code&gt;evidence&lt;/code&gt;、&lt;code&gt;solver_selection&lt;/code&gt;、&lt;code&gt;claims&lt;/code&gt;、&lt;code&gt;verification&lt;/code&gt;、&lt;code&gt;reflection&lt;/code&gt;、&lt;code&gt;confidence&lt;/code&gt;、&lt;code&gt;answer_delta&lt;/code&gt; 和 &lt;code&gt;final&lt;/code&gt; 等事件实时更新界面。它的重点不是承载复杂业务逻辑，而是提升系统可观察性。&lt;/p&gt;
&lt;h3&gt;3.2 API 层&lt;/h3&gt;
&lt;p&gt;API 层是前端和后端之间的边界，由 &lt;code&gt;app/api/routes.py&lt;/code&gt; 提供。暴露健康检查、论文库浏览、论文预览、PDF 访问、引用预览、索引重建、普通聊天、SSE 流式聊天和动态工具提案管理等接口。API 层不承担 Agent 推理，只负责接收请求、调用依赖注入得到的服务对象、处理异常并序列化响应。&lt;/p&gt;
&lt;h3&gt;3.3 Agent 编排层&lt;/h3&gt;
&lt;p&gt;Agent 编排层由 &lt;code&gt;agent/&lt;/code&gt;（26 个模块）和 &lt;code&gt;agent_mixins/&lt;/code&gt;（6 个模块）组成，是整个系统的指挥中心。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;agent/core.py&lt;/code&gt; 中的 &lt;code&gt;ResearchAssistantAgent&lt;/code&gt; 通过多重继承组合五个 Mixin 获得正交能力：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ResearchAssistantAgentV4(
    FollowupRoutingMixin,    # 追问路由：识别纠正/延续/切换
    AnswerComposerMixin,     # 答案组合：按 relation 分发到不同 answer composer
    EntityDefinitionMixin,   # 实体定义：消歧 + 定义提取
    SolverPipelineMixin,     # Claim 求解：schema / deterministic / shadow 三路径
    ClaimVerifierMixin,      # Grounding 校验：三层验证
):
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Agent 执行一条请求的完整流程在 &lt;code&gt;chat_runtime.py&lt;/code&gt; → &lt;code&gt;loop.py&lt;/code&gt; 中编排：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;run_agent_chat_turn()&lt;/code&gt; — 入口：解析 session → compress 历史 → 创建 run context → 尝试 compound → 走 standard turn&lt;/li&gt;
&lt;li&gt;&lt;code&gt;run_standard_turn()&lt;/code&gt; → &lt;code&gt;extract_agent_query_contract()&lt;/code&gt; → &lt;code&gt;planner.plan_actions()&lt;/code&gt; → &lt;code&gt;runtime.execute_*()&lt;/code&gt; → solver → verifier → composer&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loop.py&lt;/code&gt; 区分 &lt;code&gt;run_conversation_turn()&lt;/code&gt; 和 &lt;code&gt;run_research_turn()&lt;/code&gt; 两条路径&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;agent/&lt;/code&gt; 目录下的其他关键模块：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;planner.py&lt;/code&gt; — &lt;code&gt;AgentPlanner&lt;/code&gt;：tool-calling / JSON / fallback 三级 plan 生成&lt;/li&gt;
&lt;li&gt;&lt;code&gt;runtime.py&lt;/code&gt; — &lt;code&gt;AgentRuntime&lt;/code&gt;：conversation 和 research 两条 tool loop 执行路径&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tool_registries.py&lt;/code&gt; — 构建 conversation (12 工具) 和 research (19 工具) 的 &lt;code&gt;RegisteredAgentTool&lt;/code&gt; 字典&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tools.py&lt;/code&gt; — 20 个 &lt;code&gt;AgentToolSpec&lt;/code&gt;（LLM 可见） + &lt;code&gt;AgentToolExecutor&lt;/code&gt;（运行时调度）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;research_*_handlers.py&lt;/code&gt; — 四个 research 阶段的 handler：search / compose / verification / reflection&lt;/li&gt;
&lt;li&gt;&lt;code&gt;compound.py&lt;/code&gt; — 复合查询分解（”比较 DPO 和 PPO” → 两个子任务并行）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;task.py&lt;/code&gt; — &lt;code&gt;Task&lt;/code&gt; 子任务委托&lt;/li&gt;
&lt;li&gt;&lt;code&gt;trace.py&lt;/code&gt; / &lt;code&gt;trace_diff.py&lt;/code&gt; — 执行追踪与 diff&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.4 意图与规划层&lt;/h3&gt;
&lt;p&gt;意图与规划层由三个子包构成，负责理解用户问题并生成执行计划：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;intents/&lt;/code&gt;（10 模块）&lt;/strong&gt;：&lt;code&gt;router.py&lt;/code&gt; 中的 &lt;code&gt;LLMIntentRouter&lt;/code&gt; 使用 tool-calling 模式做意图路由（5 个 tool choice → 20+ 种 relation）。&lt;code&gt;research.py&lt;/code&gt;、&lt;code&gt;conversation.py&lt;/code&gt;、&lt;code&gt;library.py&lt;/code&gt;、&lt;code&gt;figure.py&lt;/code&gt;、&lt;code&gt;followup.py&lt;/code&gt;、&lt;code&gt;memory.py&lt;/code&gt; 分别处理不同类型的意图标记和 answer slot 推断。&lt;code&gt;contract_adapter.py&lt;/code&gt; 在 relation 和 answer_slots 之间做双向转换。&lt;code&gt;marker_matching.py&lt;/code&gt; 提供 &lt;code&gt;MarkerProfile&lt;/code&gt; 机制匹配用户问题中的关键词。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;planning/&lt;/code&gt;（7 模块）&lt;/strong&gt;：&lt;code&gt;research.py&lt;/code&gt; 构建 &lt;code&gt;ResearchPlan&lt;/code&gt;（召回模式、证据数量、solver 顺序）；&lt;code&gt;query_shaping.py&lt;/code&gt; 从问题中提取 targets；&lt;code&gt;query_rewrite.py&lt;/code&gt; 做多查询改写；&lt;code&gt;compound_tasks.py&lt;/code&gt; 分解复合查询；&lt;code&gt;solver_dispatch.py&lt;/code&gt; 和 &lt;code&gt;solver_goals.py&lt;/code&gt; 决定哪些 solver 需要执行。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;contracts/&lt;/code&gt;（8 模块）&lt;/strong&gt;：&lt;code&gt;session_context.py&lt;/code&gt; 构建每次 LLM 调用的会话上下文（含历史压缩）；&lt;code&gt;normalization.py&lt;/code&gt; 规范化 targets；&lt;code&gt;contextual_resolver.py&lt;/code&gt; 根据会话上下文消解实体引用；&lt;code&gt;conversation_memory.py&lt;/code&gt; 管理跨轮次的 memory bindings；&lt;code&gt;followup_relationship.py&lt;/code&gt; 处理追问关系继承。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;关于 QueryContract&lt;/strong&gt;：&lt;code&gt;QueryContract&lt;/code&gt; 仍然是意图解析后的核心数据结构（定义在 &lt;code&gt;domain/models.py&lt;/code&gt;），但它的构建不再是单一模块的责任。&lt;code&gt;extract_agent_query_contract()&lt;/code&gt; 在 &lt;code&gt;contract_extraction.py&lt;/code&gt; 中组合了 router 输出、target 抽取、followup 继承、pending clarification 处理等多个来源，最后统一规范化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;3.5 Claim 求解与验证层&lt;/h3&gt;
&lt;p&gt;这是整个系统最庞大的领域逻辑层。&lt;code&gt;claims/&lt;/code&gt; 子包有 23 个模块，是论文问答的核心引擎：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;求解器（solvers）&lt;/strong&gt;：&lt;code&gt;solver_pipeline.py&lt;/code&gt; 是总入口，调度 schema solver 和 deterministic solver。13 个 deterministic solver 各处理一种 relation（&lt;code&gt;formula_solver&lt;/code&gt;、&lt;code&gt;figure_solver&lt;/code&gt;、&lt;code&gt;table_solver&lt;/code&gt;、&lt;code&gt;text_solver&lt;/code&gt;、&lt;code&gt;origin_solver&lt;/code&gt;、&lt;code&gt;entity_definition_solver&lt;/code&gt;、&lt;code&gt;concept_definition_solver&lt;/code&gt;、&lt;code&gt;followup_research_solver&lt;/code&gt;、&lt;code&gt;generic_solver&lt;/code&gt; 等），通过 &lt;code&gt;_DETERMINISTIC_SOLVER_REGISTRY&lt;/code&gt; 注册。&lt;code&gt;deterministic_runner.py&lt;/code&gt; 提供 solver 执行基础设施。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证器（verifiers）&lt;/strong&gt;：&lt;code&gt;verifier_pipeline.py&lt;/code&gt; 编排验证流程；&lt;code&gt;type_verifiers.py&lt;/code&gt; 按 claim 类型做确定性校验（公式完整性、数值精确度、起源引用正确性）；&lt;code&gt;llm_verifier.py&lt;/code&gt; 处理需要语义判断的复杂验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;辅助模块&lt;/strong&gt;：&lt;code&gt;formula_text.py&lt;/code&gt;、&lt;code&gt;metric_text.py&lt;/code&gt;、&lt;code&gt;visual_helpers.py&lt;/code&gt; 处理公式/指标/图表的文本提取；&lt;code&gt;paper_helpers.py&lt;/code&gt;、&lt;code&gt;paper_summary.py&lt;/code&gt; 处理论文元信息；&lt;code&gt;origin_selection.py&lt;/code&gt; 处理起源论文选择。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;与 claims 紧密配合的是 &lt;code&gt;answers/&lt;/code&gt;（10 模块）、&lt;code&gt;entities/&lt;/code&gt;（4 模块）、&lt;code&gt;followup/&lt;/code&gt;（2 模块）和 &lt;code&gt;clarification/&lt;/code&gt;（3 模块），它们负责将 claim 转化为最终回答、处理实体定义、管理追问候选和生成澄清问题。&lt;/p&gt;
&lt;h3&gt;3.6 检索与数据层&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;retrieval/&lt;/code&gt;（9 模块）&lt;/strong&gt;：&lt;code&gt;core.py&lt;/code&gt; 中的 &lt;code&gt;DualIndexRetriever&lt;/code&gt; 是在线检索核心——当前默认使用 Milvus Dense 单路召回。经过严格的消融实验（159 题 × 6 配置，详见 §11.5），&lt;code&gt;text-embedding-3-large&lt;/code&gt;（3072 维）在 113 篇论文的封闭域上已达 Hit@1=97.5%，多路融合未带来增益，因此简化了默认检索路径。BM25 仍保留并修复了中文 jieba 分词；title anchor 和 relation anchor 保留为可选模块。&lt;code&gt;indexing.py&lt;/code&gt; 中的 &lt;code&gt;IngestionService&lt;/code&gt; 负责离线入库。&lt;code&gt;pdf_extractor.py&lt;/code&gt; 基于 pypdf 做 PDF 文本和信号抽取。&lt;code&gt;vector_index.py&lt;/code&gt; 封装 Milvus 向量索引。&lt;code&gt;web_search.py&lt;/code&gt; 对接 Tavily API。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;library/&lt;/code&gt;（4 模块）&lt;/strong&gt;：&lt;code&gt;zotero_sqlite.py&lt;/code&gt; 读取 Zotero 本地 SQLite；&lt;code&gt;core.py&lt;/code&gt; 提供 &lt;code&gt;LibraryBrowserService&lt;/code&gt; 论文库浏览；&lt;code&gt;metadata_sql.py&lt;/code&gt; 提供 SQL 查询论文库元信息（供 &lt;code&gt;query_library_metadata&lt;/code&gt; 工具使用）；&lt;code&gt;citation_ranking.py&lt;/code&gt; 按引用数排序论文。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;memory/&lt;/code&gt;（4 模块）&lt;/strong&gt;：&lt;code&gt;session_store.py&lt;/code&gt; 提供 &lt;code&gt;SQLiteSessionStore&lt;/code&gt;（生产）和 &lt;code&gt;InMemorySessionStore&lt;/code&gt;（测试）；&lt;code&gt;learnings.py&lt;/code&gt; 管理持久化学习；&lt;code&gt;artifacts.py&lt;/code&gt; 管理工具执行产物；&lt;code&gt;research.py&lt;/code&gt; 管理研究记忆。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.7 基础设施层&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;infra/&lt;/code&gt;（3 模块）&lt;/strong&gt;：&lt;code&gt;model_clients.py&lt;/code&gt; 中的 &lt;code&gt;ModelClients&lt;/code&gt; 统一封装 Chat（当前 &lt;code&gt;deepseek-v4-flash&lt;/code&gt;）、VLM（&lt;code&gt;gpt-4.1-mini&lt;/code&gt;）、Embedding（&lt;code&gt;text-embedding-3-large&lt;/code&gt;，走 Qihai 网关）三个模型能力。&lt;code&gt;confidence.py&lt;/code&gt; 处理置信度归一化。&lt;code&gt;prompt_safety.py&lt;/code&gt; 做输入安全检查。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;eval/&lt;/code&gt;（1 模块）&lt;/strong&gt;：&lt;code&gt;judge.py&lt;/code&gt; 提供 LLM-as-judge 评估能力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;tools/&lt;/code&gt;（3 模块）&lt;/strong&gt;：&lt;code&gt;proposals.py&lt;/code&gt; 管理动态工具提案的生命周期；&lt;code&gt;registry_helpers.py&lt;/code&gt; 提供工具注册的辅助函数；&lt;code&gt;dynamic_context.py&lt;/code&gt; 管理动态工具上下文。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. 启动入口&lt;/h2&gt;
&lt;h3&gt;4.1 app/main.py&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;app/main.py&lt;/code&gt; 是整个 FastAPI 后端的装配入口。它本身不负责论文问答、检索或 Agent 推理，而是负责把应用运行所需的几类东西串起来：读取配置、初始化日志、定义生命周期、创建 FastAPI 应用、注册 API 路由、提供前端页面（&lt;code&gt;/&lt;/code&gt; 返回 index.html，&lt;code&gt;/v4&lt;/code&gt; &lt;code&gt;/v5&lt;/code&gt; 301 重定向到 &lt;code&gt;/&lt;/code&gt;），并在依赖存在时暴露 &lt;code&gt;/metrics&lt;/code&gt; 监控指标。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncIterator
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里首先导入了基础工具。&lt;code&gt;from __future__ import annotations&lt;/code&gt; 用于延迟注解解析，&lt;code&gt;Path&lt;/code&gt; 用于处理路径。&lt;code&gt;asynccontextmanager&lt;/code&gt; 和 &lt;code&gt;AsyncIterator&lt;/code&gt; 则与后面的 lifespan 机制有关，具体放在 4.4 再展开。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里导入的是 FastAPI 应用装配相关能力。&lt;code&gt;FastAPI&lt;/code&gt; 用于创建后端应用，&lt;code&gt;CORSMiddleware&lt;/code&gt; 用于跨域配置，&lt;code&gt;FileResponse&lt;/code&gt; 用于返回静态 HTML 或 PDF 文件，&lt;code&gt;RedirectResponse&lt;/code&gt; 用于做路径跳转。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try:
    from prometheus_fastapi_instrumentator import Instrumentator
except Exception:
    Instrumentator = None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里是可选监控依赖的导入逻辑。如果当前环境安装了 &lt;code&gt;prometheus_fastapi_instrumentator&lt;/code&gt;，后面就可以用它暴露 Prometheus metrics；如果没有安装，也不会影响主应用启动。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from app.api.routes import router
from app.core.config import get_settings
from app.core.deps import close_cached_resources
from app.core.logging import setup_logging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这几行导入的是项目内部核心依赖。&lt;code&gt;router&lt;/code&gt; 是 API 路由集合，&lt;code&gt;get_settings&lt;/code&gt; 用于读取配置，&lt;code&gt;close_cached_resources&lt;/code&gt; 用于应用关闭时释放缓存资源，&lt;code&gt;setup_logging&lt;/code&gt; 用于初始化 JSON 日志。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / &quot;static&quot;

settings = get_settings()
setup_logging(settings.log_level)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里完成了入口文件的基础准备工作。&lt;code&gt;APP_DIR&lt;/code&gt; 指向 &lt;code&gt;app&lt;/code&gt; 目录，&lt;code&gt;STATIC_DIR&lt;/code&gt; 指向 &lt;code&gt;app/static&lt;/code&gt; 目录，后面 &lt;code&gt;/v4&lt;/code&gt; 会从这里返回 &lt;code&gt;index.html&lt;/code&gt;。&lt;code&gt;settings = get_settings()&lt;/code&gt; 会读取环境变量和项目 &lt;code&gt;.env&lt;/code&gt;，并确保运行时目录存在；&lt;code&gt;setup_logging(settings.log_level)&lt;/code&gt; 则根据配置初始化日志。&lt;/p&gt;
&lt;h3&gt;4.2 FastAPI 应用创建&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;app = FastAPI(
    title=settings.app_name,
    version=&quot;0.1.0&quot;,
    description=&quot;Zotero paper research agent&quot;,
    lifespan=lifespan,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码真正创建了 FastAPI 应用对象。&lt;code&gt;title&lt;/code&gt;、&lt;code&gt;version&lt;/code&gt; 和 &lt;code&gt;description&lt;/code&gt; 会出现在 OpenAPI 文档和服务元信息中。这里的 &lt;code&gt;title&lt;/code&gt; 来自 &lt;code&gt;settings.app_name&lt;/code&gt;，说明应用名称由配置统一管理。&lt;code&gt;lifespan=lifespan&lt;/code&gt; 则把应用生命周期函数注册给 FastAPI，使服务启动和关闭时可以执行自定义逻辑。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if settings.cors_allow_origins:
    app.add_middleware(
        CORSMiddleware,
        allow_origins=list(settings.cors_allow_origins),
        allow_credentials=True,
        allow_methods=[&quot;GET&quot;, &quot;POST&quot;],
        allow_headers=[&quot;Authorization&quot;, &quot;Content-Type&quot;, &quot;X-API-Key&quot;],
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建应用之后，代码会根据配置决定是否启用 CORS。只有当 &lt;code&gt;settings.cors_allow_origins&lt;/code&gt; 不为空时，才会添加 &lt;code&gt;CORSMiddleware&lt;/code&gt;。这样可以避免默认放开跨域访问，同时在需要前后端分离或跨域调试时，通过环境变量显式配置允许访问的前端来源。&lt;/p&gt;
&lt;h3&gt;4.3 路由与静态页面挂载&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;app.include_router(router, prefix=&quot;/api/v1&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这句把 &lt;code&gt;app/api/routes.py&lt;/code&gt; 中定义的 API 路由统一挂载到 &lt;code&gt;/api/v1&lt;/code&gt; 前缀下面。也就是说，路由文件里定义的 &lt;code&gt;/v4/chat&lt;/code&gt;、&lt;code&gt;/v4/health&lt;/code&gt; 等接口，最终会变成 &lt;code&gt;/api/v1/chat&lt;/code&gt;、&lt;code&gt;/api/v1/health&lt;/code&gt;。其中 &lt;code&gt;/api/v1&lt;/code&gt; 是 接口协议版本，&lt;code&gt;/api/v1&lt;/code&gt; 为协议版本前缀。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/&quot;, include_in_schema=False)
def root() -&amp;gt; RedirectResponse:
    return RedirectResponse(url=&quot;/&quot;, status_code=307)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;根路径 &lt;code&gt;/&lt;/code&gt; 提供一个兜底跳转，用于本地直连或反向代理未单独配置首页时，直接返回前端页面（当前运行时版本）。在线上部署中，真实入口通常由域名和 Nginx 配置决定，例如 &lt;code&gt;owen571.top&lt;/code&gt; 可以直接作为用户访问入口，所以这里不应该理解成项目唯一入口，而只是 FastAPI 内部的默认访问兜底。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/legacy&quot;, include_in_schema=False)
def ui_legacy() -&amp;gt; FileResponse:
    return FileResponse(
        STATIC_DIR / &quot;index.html&quot;,
        headers={
            &quot;Cache-Control&quot;: &quot;no-store, no-cache, must-revalidate, max-age=0&quot;,
            &quot;Pragma&quot;: &quot;no-cache&quot;,
            &quot;Expires&quot;: &quot;0&quot;,
        },
    )


@app.get(&quot;/v5&quot;, include_in_schema=False)
def ui_index() -&amp;gt; FileResponse:
    return FileResponse(
        STATIC_DIR / &quot;index.html&quot;,
        headers={
            &quot;Cache-Control&quot;: &quot;no-store, no-cache, must-revalidate, max-age=0&quot;,
            &quot;Pragma&quot;: &quot;no-cache&quot;,
            &quot;Expires&quot;: &quot;0&quot;,
        },
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/v4&lt;/code&gt; 和 &lt;code&gt;/v5&lt;/code&gt; 返回的是同一个静态 HTML 页面，也就是 &lt;code&gt;app/static/index.html&lt;/code&gt;。浏览器拿到这个页面后，会执行其中的 JavaScript，再去请求 &lt;code&gt;/api/v1/chat/stream&lt;/code&gt;、&lt;code&gt;/api/v1/library&lt;/code&gt; 等后端接口。这里设置 &lt;code&gt;Cache-Control: no-store&lt;/code&gt; 是为了避免浏览器缓存旧版前端页面，方便前端持续迭代和线上刷新。两个路径并存是为了兼容不同版本的前端入口（&lt;code&gt;/v4&lt;/code&gt; 和 &lt;code&gt;/v5&lt;/code&gt; 指向同一个最新前端页面，当前运行时版本为 V5）。&lt;/p&gt;
&lt;h3&gt;4.4 lifespan 资源释放&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;@asynccontextmanager
async def lifespan(_: FastAPI) -&amp;gt; AsyncIterator[None]:
    try:
        yield
    finally:
        await close_cached_resources()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里，&lt;code&gt;asynccontextmanager&lt;/code&gt; 是一个装饰器，用来把带有 &lt;code&gt;yield&lt;/code&gt; 的异步函数变成异步上下文管理器。它用一种简单的方式表达了应用启动时进入、应用运行中停在 &lt;code&gt;yield&lt;/code&gt;、应用关闭时执行 &lt;code&gt;finally&lt;/code&gt;。FastAPI 在实例化时接收 &lt;code&gt;lifespan=lifespan&lt;/code&gt;，就能自动识别启动和关闭时要执行的逻辑。它近似做了这件事：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cm = lifespan(app)

await cm.__aenter__()   # 执行 yield 前面的代码
try:
    await run_server()  # 应用运行中
finally:
    await cm.__aexit__()  # 继续执行 yield 后面和 finally
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前项目没有在启动阶段额外初始化重资源，所以直接 &lt;code&gt;yield&lt;/code&gt; 提供服务。运行阶段由 FastAPI 处理各种请求；关闭阶段执行 &lt;code&gt;finally&lt;/code&gt;，调用 &lt;code&gt;close_cached_resources()&lt;/code&gt; 释放模型客户端、Retriever、HTTP 连接池等缓存资源。&lt;/p&gt;
&lt;h3&gt;4.5 Prometheus metrics&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;if Instrumentator is not None:
    Instrumentator(
        should_group_status_codes=True,
        should_ignore_untemplated=True,
    ).instrument(app).expose(app, include_in_schema=False, endpoint=&quot;/metrics&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;prometheus_fastapi_instrumentator&lt;/code&gt; 成功导入，应用就会暴露 &lt;code&gt;/metrics&lt;/code&gt; 运维观测入口。Prometheus 会定时抓取这个接口并存储指标，Grafana 再读取 Prometheus 数据并画图。当前项目的基础 metrics 包括 HTTP 请求数、响应状态码、接口耗时、Python GC、进程内存、CPU 和文件描述符等。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage.unilGVGi.png&amp;amp;w=3448&amp;amp;h=1666&amp;amp;f=webp&quot; alt=&quot;Prometheus 监控面板&quot; /&gt;&lt;/p&gt;
&lt;p&gt;从监控面板上可以看出几类信息。流量方面，&lt;code&gt;Chat Stream Request Rate = 0.00702 req/s&lt;/code&gt;，表示最近五分钟平均每秒请求数；&lt;code&gt;HTTP Request Rate by Handler&lt;/code&gt; 中 &lt;code&gt;/metrics&lt;/code&gt; 最高，这是因为 Prometheus 每 15 秒抓一次，&lt;code&gt;1 / 15 = 0.0667 req/s&lt;/code&gt;。延迟方面，&lt;code&gt;Chat Stream p95 Latency = 1s&lt;/code&gt;，表明 95% 的 &lt;code&gt;/chat/stream&lt;/code&gt; 请求耗时不超过约 1 秒；不过这个指标来自 FastAPI HTTP 层，对于 SSE 流式接口来说，它不完全等价于用户感知的首 token 延迟。错误方面，&lt;code&gt;5xx Error Rate = No data&lt;/code&gt; 通常表示最近没有出现 5xx 错误。资源方面，内存如果长期持续上涨不下降就要怀疑泄漏；CPU 当前很低，说明服务基本空闲；Open File Descriptors 也很低，说明没有明显连接泄漏或文件句柄泄漏。&lt;/p&gt;
&lt;h2&gt;5. API 路由&lt;/h2&gt;
&lt;p&gt;API 路由层主要集中在 app/api/routes.py，负责把 FastAPI 的 HTTP 请求转换为对后端服务层和 Agent 层的调用。它通过 APIRouter 定义 /v4/* 系列接口，并在 main.py 中统一挂载到 /api/v1 前缀下。该层不直接实现复杂业务逻辑，而是负责参数接收、依赖注入、权限校验、异常转换、响应模型封装和 SSE 流式事件输出，是前端与后端核心能力之间的边界层。&lt;/p&gt;
&lt;p&gt;这里，我们先用 &lt;code&gt;app.schemas.api&lt;/code&gt; 中定义的一系列Schema，来规范返回的格式。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from app.schemas.api import (
    AgentChatRequest,
    AgentChatResponse,
    AgentCitation,
    CitationPreviewResponse,
    HealthResponse,
    IngestRequest,
    IngestResponse,
    LibraryResponse,
    PaperPreviewResponse,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.1 health&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;health&lt;/code&gt; 是最简单的状态检查接口，用来判断后端服务是否已经正常启动，并让前端确认当前加载的是 V4 的新版运行时。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/v4/health&quot;, response_model=HealthResponse)
def health() -&amp;gt; HealthResponse:
    return HealthResponse()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个接口没有请求参数，也不会触发 Agent、检索器或模型调用，只是直接返回一个 &lt;code&gt;HealthResponse&lt;/code&gt;。由于 &lt;code&gt;routes.py&lt;/code&gt; 中的路由会在 &lt;code&gt;main.py&lt;/code&gt; 里统一挂载到 &lt;code&gt;/api/v1&lt;/code&gt; 前缀下，所以它的真实访问路径是 &lt;code&gt;/api/v1/health&lt;/code&gt;。前端启动时会请求这个接口，用返回值判断服务是否在线、当前 runtime 是否支持结构化摘要，以及后端暴露的 canonical tools 是否为 &lt;code&gt;read_memory&lt;/code&gt;、&lt;code&gt;search_corpus&lt;/code&gt;、&lt;code&gt;web_search&lt;/code&gt;、&lt;code&gt;query_library_metadata&lt;/code&gt;、&lt;code&gt;compose&lt;/code&gt;、&lt;code&gt;ask_human&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class HealthResponse(BaseModel):
    status: str = &quot;ok&quot;
    runtime_profile: str = &quot;structured-intent-react-loop&quot;
    runtime_summary_supported: bool = True
    canonical_tools: list[str] = Field(
        default_factory=lambda: [&quot;read_memory&quot;, &quot;search_corpus&quot;, &quot;web_search&quot;, &quot;query_library_metadata&quot;, &quot;compose&quot;, &quot;ask_human&quot;]
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.2 library&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;library&lt;/code&gt; 接口用于返回当前论文库的整体列表，是前端左侧 Zotero Corpus 侧栏的数据来源。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/v4/library&quot;, response_model=LibraryResponse)
def library(
    library_service: LibraryBrowserService = Depends(get_library_service),
) -&amp;gt; LibraryResponse:
    return LibraryResponse(**library_service.list_library())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个接口通过 &lt;code&gt;Depends(get_library_service)&lt;/code&gt; 获取 &lt;code&gt;LibraryBrowserService&lt;/code&gt; 实例。&lt;code&gt;Depends&lt;/code&gt; 是 FastAPI 的依赖注入机制，意思是请求进入这个接口时，FastAPI 会先调用 &lt;code&gt;get_library_service()&lt;/code&gt;，把返回的服务对象传给 &lt;code&gt;library_service&lt;/code&gt; 参数。真正读取论文库、整理分类、生成论文列表的逻辑不写在路由层，而是交给 &lt;code&gt;library_service.list_library()&lt;/code&gt; 完成。&lt;/p&gt;
&lt;p&gt;接口返回值被声明为 &lt;code&gt;LibraryResponse&lt;/code&gt;，LibraryResponse 是论文库接口的最外层响应，表示“整个论文库浏览结果”。它里面有一个 categories，类型是 list[LibraryCategory]，表示按 Zotero collection、标签或“未分类”分组后的论文列表；还有一个 total_papers，表示当前论文库里去重后的论文总数。LibraryCategory 表示一个分类分组，name 是分类名，count 是这个分类下有多少篇论文，papers 是该分类下的论文列表。最里面的 LibraryPaper 是前端论文卡片需要的最小展示单元，包含 paper_id、title、authors、year、tags、categories、file_path、preview 等字段。&lt;/p&gt;
&lt;h3&gt;5.3 paper preview / pdf&lt;/h3&gt;
&lt;p&gt;这一节对应两个论文预览相关接口：一个返回论文的结构化预览信息，另一个返回真实 PDF 文件。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/v4/library/papers/{paper_id}/preview&quot;, response_model=PaperPreviewResponse)
def paper_preview(
    paper_id: str,
    library_service: LibraryBrowserService = Depends(get_library_service),
) -&amp;gt; PaperPreviewResponse:
    payload = library_service.paper_preview(paper_id)
    if payload is None:
        raise HTTPException(status_code=404, detail=&quot;paper not found&quot;)
    return PaperPreviewResponse(**payload)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;paper_preview&lt;/code&gt; 用于根据 &lt;code&gt;paper_id&lt;/code&gt; 返回某篇论文的预览信息。这里的 &lt;code&gt;paper_id&lt;/code&gt; 来自 URL 路径，例如 &lt;code&gt;/api/v1/library/papers/xxx/preview&lt;/code&gt;。路由层会调用 &lt;code&gt;library_service.paper_preview(paper_id)&lt;/code&gt;，如果找不到对应论文，就抛出 &lt;code&gt;404 paper not found&lt;/code&gt;；如果找到，就包装成 &lt;code&gt;PaperPreviewResponse&lt;/code&gt; 返回。这个响应里包含论文基础信息和若干证据片段，供前端右侧 Preview 面板展示。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/v4/library/papers/{paper_id}/pdf&quot;)
def paper_pdf(
    paper_id: str,
    _: None = Depends(require_pdf_access),
    library_service: LibraryBrowserService = Depends(get_library_service),
) -&amp;gt; FileResponse:
    path = library_service.pdf_path(paper_id)
    if path is None:
        raise HTTPException(status_code=404, detail=&quot;pdf not found&quot;)
    return FileResponse(path, media_type=&quot;application/pdf&quot;, filename=path.name, content_disposition_type=&quot;inline&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;paper_pdf&lt;/code&gt; 用于返回某篇论文的 PDF 文件。这个接口比 preview 更敏感，因为它会直接把 Zotero 本地 PDF 文件发给浏览器，所以这里增加了 &lt;code&gt;Depends(require_pdf_access)&lt;/code&gt; 做访问控制。&lt;code&gt;library_service.pdf_path(paper_id)&lt;/code&gt; 会解析并校验 PDF 路径，如果文件不存在、不是 PDF，或者不在允许的 Zotero 路径范围内，就返回 &lt;code&gt;None&lt;/code&gt;，接口再抛出 &lt;code&gt;404 pdf not found&lt;/code&gt;。成功时，&lt;code&gt;FileResponse&lt;/code&gt; 会以 &lt;code&gt;application/pdf&lt;/code&gt; 类型返回文件，并通过 &lt;code&gt;content_disposition_type=&quot;inline&quot;&lt;/code&gt; 让浏览器尽量以内嵌预览方式打开，而不是直接下载。&lt;/p&gt;
&lt;h3&gt;5.4 citation preview&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;citation preview&lt;/code&gt; 用于根据回答中的引用信息，反查对应的证据片段，给前端的引用预览面板使用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/v4/citations/preview&quot;, response_model=CitationPreviewResponse)
def citation_preview(
    doc_id: str = Query(default=&quot;&quot;),
    paper_id: str = Query(default=&quot;&quot;),
    library_service: LibraryBrowserService = Depends(get_library_service),
) -&amp;gt; CitationPreviewResponse:
    payload = library_service.citation_preview(doc_id=doc_id, paper_id=paper_id)
    if payload is None:
        raise HTTPException(status_code=404, detail=&quot;citation evidence not found&quot;)
    return CitationPreviewResponse(**payload)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个接口和前面的论文预览不同，它不是按路径参数接收 &lt;code&gt;paper_id&lt;/code&gt;，而是通过 query 参数接收 &lt;code&gt;doc_id&lt;/code&gt; 和 &lt;code&gt;paper_id&lt;/code&gt;，真实访问形式类似 &lt;code&gt;/api/v1/citations/preview?doc_id=xxx&amp;amp;paper_id=yyy&lt;/code&gt;。其中 &lt;code&gt;doc_id&lt;/code&gt; 更精确，指向某一个 evidence block；&lt;code&gt;paper_id&lt;/code&gt; 更粗，指向某一篇论文。服务层会优先用 &lt;code&gt;doc_id&lt;/code&gt; 找 block 级证据，如果找不到，再尝试用 &lt;code&gt;paper_id&lt;/code&gt; 找论文级信息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class CitationPreviewResponse(BaseModel):
    paper_id: str = &quot;&quot;
    doc_id: str = &quot;&quot;
    title: str = &quot;&quot;
    authors: str = &quot;&quot;
    year: str = &quot;&quot;
    file_path: str = &quot;&quot;
    page: int = 0
    block_type: str = &quot;&quot;
    caption: str = &quot;&quot;
    snippet: str = &quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回结果里既包含论文元信息，也包含页码、块类型、图注和证据片段。这样前端在用户点击回答里的引用时，不需要重新跑 Agent，也不需要重新检索，只要拿着引用里的 &lt;code&gt;doc_id&lt;/code&gt; 或 &lt;code&gt;paper_id&lt;/code&gt; 调这个接口，就能展示对应来源。换句话说，&lt;code&gt;citation preview&lt;/code&gt; 是“回答引用”到“原始证据”的轻量跳转接口，它主要服务于可追踪性和结果校验。&lt;/p&gt;
&lt;h3&gt;5.5 ingest rebuild&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ingest rebuild&lt;/code&gt; 是索引重建接口，用来把 Zotero 论文库重新抽取、切块、入库，并更新本地检索索引。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.post(&quot;/v4/ingest/rebuild&quot;, response_model=IngestResponse)
def ingest_rebuild(
    payload: IngestRequest,
    _: None = Depends(require_admin_access),
    ingestion_service: IngestionService = Depends(get_ingestion_service),
) -&amp;gt; IngestResponse:
    try:
        stats = ingestion_service.rebuild(max_papers=payload.max_papers, force_rebuild=payload.force_rebuild)
        get_retriever().refresh()
    except Exception as exc:
        logger.exception(&quot;v4 ingest rebuild failed&quot;)
        raise HTTPException(status_code=500, detail=&quot;ingest rebuild failed&quot;) from exc
    return IngestResponse(message=&quot;v4 ingestion completed&quot;, **stats.to_dict())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个接口是 &lt;code&gt;POST&lt;/code&gt; 请求，因为它会改变系统状态。它不是给普通用户频繁点击的问答接口，而是管理员在新增论文、修改 Zotero 库、重建向量索引时使用的维护接口。所以参数中有 &lt;code&gt;Depends(require_admin_access)&lt;/code&gt;，请求必须带正确的 admin API key，否则会返回 401；如果服务端没有配置 &lt;code&gt;ADMIN_API_KEY&lt;/code&gt;，则会返回 503，避免危险接口在无保护状态下暴露。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class IngestRequest(BaseModel):
    force_rebuild: bool = True
    max_papers: int | None = Field(default=None, ge=1)

class IngestResponse(BaseModel):
    message: str
    paper_records: int = 0
    papers_indexed: int = 0
    papers_missing_pdf: int = 0
    block_docs: int = 0
    paper_docs: int = 0
    vectors_upserted: int = 0
    papers_with_generated_summary: int = 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;max_papers&lt;/code&gt; 用于限制本次最多处理多少篇论文，适合调试或小规模验证；&lt;code&gt;force_rebuild&lt;/code&gt; 会传给向量索引层，如果为 true，会重建 Milvus collection 后再写入向量。返回值里的 &lt;code&gt;paper_records&lt;/code&gt; 表示从 Zotero 读取到的记录数，&lt;code&gt;papers_indexed&lt;/code&gt; 表示成功完成 PDF 抽取并入库的论文数，&lt;code&gt;papers_missing_pdf&lt;/code&gt; 表示 Zotero 里有记录但本地 PDF 缺失的数量，&lt;code&gt;paper_docs&lt;/code&gt; 是论文级索引文档数，&lt;code&gt;block_docs&lt;/code&gt; 是 PDF 页面、段落、表格、图注等证据块文档数，&lt;code&gt;vectors_upserted&lt;/code&gt; 是写入 Milvus 的向量数量。&lt;/p&gt;
&lt;p&gt;它的完整链路是：路由层接收请求并校验管理员权限，&lt;code&gt;IngestionService.rebuild()&lt;/code&gt; 读取 Zotero 记录，调用 PDF 抽取器解析页面内容，生成 paper card 和 block documents，写入 &lt;code&gt;papers.jsonl&lt;/code&gt;、&lt;code&gt;blocks.jsonl&lt;/code&gt; 和 ingestion state；如果配置了 embedding 所需的 API key，还会把论文级文档和证据块文档写入 Milvus。最后，路由层调用 &lt;code&gt;get_retriever().refresh()&lt;/code&gt;，让正在运行的服务重新加载本地 JSONL 和 BM25 索引，这样重建完成后前端查询可以立即使用新论文库。&lt;/p&gt;
&lt;h3&gt;5.6 chat / stream chat&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;chat&lt;/code&gt; 和 &lt;code&gt;chat/stream&lt;/code&gt; 是前端真正发起论文问答的两个入口。它们使用同一个请求模型 &lt;code&gt;AgentChatRequest&lt;/code&gt;，区别在于返回方式不同：&lt;code&gt;chat&lt;/code&gt; 等 Agent 全部运行结束后一次性返回完整 JSON；&lt;code&gt;chat/stream&lt;/code&gt; 则通过 SSE 把运行事件和回答增量实时推给前端。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AgentChatRequest(BaseModel):
    query: str = Field(min_length=1)
    session_id: str | None = None
    mode: str = &quot;auto&quot;
    use_web_search: bool = False
    max_web_results: int = Field(default=3, ge=1, le=10)
    clarification_choice: dict[str, Any] | None = None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;query&lt;/code&gt; 是用户输入的问题，&lt;code&gt;session_id&lt;/code&gt; 用于延续多轮对话，&lt;code&gt;mode&lt;/code&gt; 默认为 &lt;code&gt;auto&lt;/code&gt;，让 Agent 自己判断是普通对话还是研究任务。&lt;code&gt;use_web_search&lt;/code&gt; 控制是否允许补充 Web Search，&lt;code&gt;max_web_results&lt;/code&gt; 限制网页搜索数量，&lt;code&gt;clarification_choice&lt;/code&gt; 用于处理上一轮 Agent 反问用户后的选择结果。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.post(&quot;/v4/chat&quot;, response_model=AgentChatResponse)
async def agent_chat_v4(
    payload: AgentChatRequest,
    agent: ResearchAssistantAgent = Depends(get_agent),
) -&amp;gt; AgentChatResponse:
    try:
        result = await agent.achat(
            query=payload.query,
            session_id=payload.session_id,
            mode=payload.mode,
            use_web_search=payload.use_web_search,
            max_web_results=payload.max_web_results,
            clarification_choice=payload.clarification_choice,
        )
    except Exception as exc:
        logger.exception(&quot;v4 chat failed&quot;)
        raise HTTPException(status_code=500, detail=&quot;chat failed&quot;) from exc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通 &lt;code&gt;chat&lt;/code&gt; 接口通过 &lt;code&gt;Depends(get_agent)&lt;/code&gt; 拿到全局缓存的 &lt;code&gt;ResearchAssistantAgent&lt;/code&gt;，然后调用 &lt;code&gt;agent.achat()&lt;/code&gt;。虽然路由函数是 async，但 Agent 内部主要是同步的检索、规划和模型调用，所以 &lt;code&gt;achat()&lt;/code&gt; 实际上会用 &lt;code&gt;asyncio.to_thread()&lt;/code&gt; 把同步 &lt;code&gt;chat()&lt;/code&gt; 放到线程里执行，避免长时间阻塞 FastAPI 的事件循环。接口如果执行失败，会记录异常日志，并统一返回 &lt;code&gt;500 chat failed&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;citation_models = [AgentCitation(**item) for item in result.get(&quot;citations&quot;, [])]
return AgentChatResponse(
    session_id=str(result.get(&quot;session_id&quot;, &quot;&quot;)),
    interaction_mode=str(result.get(&quot;interaction_mode&quot;, &quot;&quot;)),
    answer=str(result.get(&quot;answer&quot;, &quot;&quot;)),
    citations=citation_models,
    query_contract=dict(result.get(&quot;query_contract&quot;, {})),
    research_plan_summary=dict(result.get(&quot;research_plan_summary&quot;, {})),
    runtime_summary=dict(result.get(&quot;runtime_summary&quot;, {})),
    execution_steps=list(result.get(&quot;execution_steps&quot;, [])),
    verification_report=dict(result.get(&quot;verification_report&quot;, {})),
    needs_human=bool(result.get(&quot;needs_human&quot;, False)),
    clarification_question=str(result.get(&quot;clarification_question&quot;, &quot;&quot;)),
    clarification_options=list(result.get(&quot;clarification_options&quot;, [])),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里可以看到，&lt;code&gt;chat&lt;/code&gt; 的返回不只是最终答案，还包括引用、结构化意图、研究计划摘要、运行时摘要、执行步骤、验证报告和澄清信息。因此它更像是一次完整 Agent 运行结果的快照，适合调试、测试或不需要实时流式展示的调用场景。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.post(&quot;/v4/chat/stream&quot;)
async def agent_chat_v4_stream(
    payload: AgentChatRequest,
    agent: ResearchAssistantAgent = Depends(get_agent),
) -&amp;gt; StreamingResponse:
    async def event_stream() -&amp;gt; object:
        try:
            async for item in agent.astream_chat_events(
                query=payload.query,
                session_id=payload.session_id,
                mode=payload.mode,
                use_web_search=payload.use_web_search,
                max_web_results=payload.max_web_results,
                clarification_choice=payload.clarification_choice,
            ):
                yield _format_sse(str(item.get(&quot;event&quot;, &quot;message&quot;)), item.get(&quot;data&quot;, {}))
        except Exception as exc:
            logger.exception(&quot;v4 stream failed&quot;)
            for event, data in _stream_error_events(exc):
                yield _format_sse(event, data)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;chat/stream&lt;/code&gt; 是前端主要使用的接口。它没有声明 &lt;code&gt;response_model&lt;/code&gt;，因为它返回的不是一个普通 JSON，而是一条持续输出的事件流。&lt;code&gt;event_stream()&lt;/code&gt; 是一个异步生成器，会不断从 &lt;code&gt;agent.astream_chat_events()&lt;/code&gt; 里拿事件，再用 &lt;code&gt;_format_sse()&lt;/code&gt; 转成 SSE 格式。SSE 的基本格式是 &lt;code&gt;event: 事件名&lt;/code&gt; 加 &lt;code&gt;data: JSON字符串&lt;/code&gt;，中间用空行分隔，浏览器可以边接收边渲染。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _format_sse(event: str, data: object) -&amp;gt; str:
    return f&quot;event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Agent 在运行过程中会不断产生 &lt;code&gt;session&lt;/code&gt;、&lt;code&gt;contract&lt;/code&gt;、&lt;code&gt;agent_plan&lt;/code&gt;、&lt;code&gt;plan&lt;/code&gt;、&lt;code&gt;observation&lt;/code&gt;、&lt;code&gt;agent_step&lt;/code&gt;、&lt;code&gt;thinking_delta&lt;/code&gt;、&lt;code&gt;tool_call&lt;/code&gt;、&lt;code&gt;candidate_papers&lt;/code&gt;、&lt;code&gt;screened_papers&lt;/code&gt;、&lt;code&gt;evidence&lt;/code&gt;、&lt;code&gt;solver_selection&lt;/code&gt;、&lt;code&gt;claims&lt;/code&gt;、&lt;code&gt;verification&lt;/code&gt;、&lt;code&gt;reflection&lt;/code&gt;、&lt;code&gt;confidence&lt;/code&gt;、&lt;code&gt;answer_delta&lt;/code&gt;、&lt;code&gt;final&lt;/code&gt; 等事件。前端收到这些事件后，就能实时更新 Runtime 面板、引用列表和回答正文。最新的 LLM-judge 候选消歧也会通过 &lt;code&gt;observation&lt;/code&gt; 暴露出来，例如 &lt;code&gt;summary=options=4, judge=auto_resolve, confidence=0.95&lt;/code&gt;。相比普通 &lt;code&gt;chat&lt;/code&gt;，&lt;code&gt;chat/stream&lt;/code&gt; 的价值在于可观察性更强，用户不用等完整答案生成完，前端也能展示 Agent 正在理解问题、调用工具、检索证据、消解候选并组合答案的过程。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return StreamingResponse(
    event_stream(),
    media_type=&quot;text/event-stream&quot;,
    headers={
        &quot;Cache-Control&quot;: &quot;no-cache&quot;,
        &quot;Connection&quot;: &quot;keep-alive&quot;,
        &quot;X-Accel-Buffering&quot;: &quot;no&quot;,
    },
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后返回的 &lt;code&gt;StreamingResponse&lt;/code&gt; 指定了 &lt;code&gt;text/event-stream&lt;/code&gt;，这是 SSE 的标准媒体类型。&lt;code&gt;Cache-Control: no-cache&lt;/code&gt; 避免中间层缓存流式结果，&lt;code&gt;Connection: keep-alive&lt;/code&gt; 保持连接不断开，&lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; 则是给 Nginx 的提示，避免反向代理把事件攒在一起后再一次性返回。这个接口本质上就是把 Agent 内部运行轨迹包装成浏览器可以消费的实时事件流。&lt;/p&gt;
&lt;p&gt;流式返回的总链路：用户点击发送
-&amp;gt; fetch POST /api/v1/chat/stream
-&amp;gt; FastAPI StreamingResponse 打开长连接
-&amp;gt; Agent 在线程里执行 run_agent_chat_turn()
-&amp;gt; 执行过程中 emit_event(item) 把事件放入 asyncio.Queue
-&amp;gt; astream_chat_events() 从 queue 取事件并 yield
-&amp;gt; routes.py 转成 SSE 文本
-&amp;gt; 浏览器 reader.read() 持续读取
-&amp;gt; parseSse() 解析 event/data
-&amp;gt; answer_delta 追加回答，final 收尾&lt;/p&gt;
&lt;h3&gt;5.7 动态工具提案管理 API&lt;/h3&gt;
&lt;p&gt;这是一组管理员接口，用于管理动态注册的 Agent 工具提案，支持从提案创建、沙盒测试到正式启用的完整生命周期。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/v4/admin/tools/proposals&quot;)
def admin_list_tool_proposals(
    include_code: bool = Query(default=False),
    _: None = Depends(require_admin_access),
    settings: Settings = Depends(get_settings),
) -&amp;gt; dict[str, object]:
    return {&quot;items&quot;: list_tool_proposals(data_dir=settings.data_dir, include_code=include_code)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GET /api/v1/admin/tools/proposals&lt;/code&gt; 列出所有工具提案，可选参数 &lt;code&gt;include_code&lt;/code&gt; 控制是否返回提案中的 Python 代码。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/v4/admin/tools/proposals/{proposal_id}&quot;)
def admin_get_tool_proposal(proposal_id: str, ...) -&amp;gt; dict[str, object]:
    return load_tool_proposal(data_dir=settings.data_dir, proposal_id=proposal_id, include_code=include_code)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GET /api/v1/admin/tools/proposals/{proposal_id}&lt;/code&gt; 获取单个工具提案的完整内容，包括代码和元信息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.post(&quot;/v4/admin/tools/proposals/{proposal_id}/sandbox&quot;)
def admin_run_tool_proposal_sandbox(proposal_id: str, payload: ToolProposalSandboxRequest, ...):
    return run_tool_proposal_sandbox(
        proposal_path=proposal_path,
        args=payload.args,
        timeout_seconds=payload.timeout_seconds,
        memory_limit_mb=payload.memory_limit_mb,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;POST /api/v1/admin/tools/proposals/{proposal_id}/sandbox&lt;/code&gt; 在沙盒环境中执行工具提案代码，验证其功能是否正常。&lt;code&gt;ToolProposalSandboxRequest&lt;/code&gt; 包含 &lt;code&gt;args&lt;/code&gt;（工具参数）、&lt;code&gt;timeout_seconds&lt;/code&gt;（超时限制，最大 30s）和 &lt;code&gt;memory_limit_mb&lt;/code&gt;（内存限制，64-2048 MB）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@router.post(&quot;/v4/admin/tools/proposals/{proposal_id}/status&quot;)
def admin_transition_tool_proposal_status(proposal_id: str, payload: ToolProposalTransitionRequest, ...):
    return transition_tool_proposal_status(
        proposal_path=proposal_path,
        next_status=payload.next_status,
        code_sha256=payload.code_sha256,
        reviewer=payload.reviewer,
        note=payload.note,
        sandbox_report=payload.sandbox_report,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;POST /api/v1/admin/tools/proposals/{proposal_id}/status&lt;/code&gt; 切换工具提案的状态（如 &lt;code&gt;draft&lt;/code&gt; → &lt;code&gt;sandboxed&lt;/code&gt; → &lt;code&gt;active&lt;/code&gt; 或 &lt;code&gt;deprecated&lt;/code&gt;）。&lt;code&gt;ToolProposalTransitionRequest&lt;/code&gt; 包含 &lt;code&gt;next_status&lt;/code&gt;（下一状态）、&lt;code&gt;code_sha256&lt;/code&gt;（代码哈希用于校验完整性）、&lt;code&gt;reviewer&lt;/code&gt;（审核人）、&lt;code&gt;note&lt;/code&gt;（备注）和 &lt;code&gt;sandbox_report&lt;/code&gt;（沙盒测试报告）。&lt;/p&gt;
&lt;p&gt;所有工具提案接口都需要通过 &lt;code&gt;require_admin_access&lt;/code&gt; 校验管理员身份，与 &lt;code&gt;ingest rebuild&lt;/code&gt; 一样的安全控制。&lt;/p&gt;
&lt;h2&gt;6. 数据模型&lt;/h2&gt;
&lt;h3&gt;6.1 API schema&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;app.schemas.api&lt;/code&gt; 里的 &lt;code&gt;BaseModel&lt;/code&gt; 负责规定前端和后端之间的数据格式。它们更像 HTTP 边界上的“合同”：前端传进来的请求必须满足请求 schema，后端返回给前端的数据也要满足响应 schema。比如 &lt;code&gt;AgentChatRequest&lt;/code&gt; 约束了 &lt;code&gt;query&lt;/code&gt; 不能为空，&lt;code&gt;max_web_results&lt;/code&gt; 必须在 1 到 10 之间；&lt;code&gt;AgentChatResponse&lt;/code&gt; 则规定了一次问答最终要返回 &lt;code&gt;answer&lt;/code&gt;、&lt;code&gt;citations&lt;/code&gt;、&lt;code&gt;query_contract&lt;/code&gt;、&lt;code&gt;runtime_summary&lt;/code&gt;、&lt;code&gt;verification_report&lt;/code&gt; 和澄清相关字段。&lt;/p&gt;
&lt;p&gt;如果请求不满足 schema，FastAPI 会在进入路由函数之前返回 &lt;code&gt;422&lt;/code&gt;。如果后端构造出来的响应不满足 &lt;code&gt;response_model&lt;/code&gt; 或手动实例化的 Pydantic schema，就说明后端实现违反了自己的接口合同，通常会变成服务端异常。也就是说，API schema 的价值不只是“写注解”，而是把前后端交互格式固定下来，让错误尽早暴露。&lt;/p&gt;
&lt;h3&gt;6.2 domain models&lt;/h3&gt;
&lt;p&gt;这是更靠近 Agent 内部推理的数据结构，比如 &lt;code&gt;QueryContract&lt;/code&gt;、&lt;code&gt;SessionContext&lt;/code&gt;、&lt;code&gt;ResearchPlan&lt;/code&gt;、&lt;code&gt;CandidatePaper&lt;/code&gt;、&lt;code&gt;DisambiguationJudgeDecision&lt;/code&gt;、&lt;code&gt;EvidenceBlock&lt;/code&gt;、&lt;code&gt;Claim&lt;/code&gt;、&lt;code&gt;VerificationReport&lt;/code&gt;、&lt;code&gt;AssistantCitation&lt;/code&gt;、&lt;code&gt;AssistantResponse&lt;/code&gt; 等。它们不是给前端页面直接展示用的，而是 Agent 在理解问题、规划检索、筛选论文、消解歧义、抽取证据、生成 claim、做 grounding 校验和组织最终答案时使用的内部协议。&lt;/p&gt;
&lt;p&gt;用一句话概括：API schema 是后端对前端说话的格式，domain models 是 Agent 内部思考和协作的格式。没有这些结构，系统就会退化成到处传 &lt;code&gt;dict&lt;/code&gt;，每个模块都靠记忆猜字段名，很容易出现 &lt;code&gt;target&lt;/code&gt;、&lt;code&gt;targets&lt;/code&gt;、&lt;code&gt;paper_titles&lt;/code&gt;、&lt;code&gt;active_titles&lt;/code&gt; 混用的问题。&lt;/p&gt;
&lt;p&gt;当前研究链路可以概括为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户问题
-&amp;gt; AgentChatRequest：API 接收用户请求
-&amp;gt; QueryContract：把自然语言问题变成结构化研究意图
-&amp;gt; ResearchPlan：决定召回方式、证据数量和 solver 顺序
-&amp;gt; CandidatePaper：论文级候选
-&amp;gt; DisambiguationJudgeDecision：在候选有歧义时判断是否可自动绑定
-&amp;gt; EvidenceBlock：具体证据块
-&amp;gt; Claim：基于证据提炼出的结论
-&amp;gt; VerificationReport：检查 claim 是否被证据支持，是否需要 retry 或澄清
-&amp;gt; AssistantResponse：最终回答、引用、运行摘要和澄清信息
-&amp;gt; SessionContext / SessionTurn：把本轮研究写入多轮记忆
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.3 QueryContract&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;QueryContract&lt;/code&gt; 是用户问题进入 Agent 后最关键的结构化意图对象。它把一句自然语言问题拆成后续模块可以执行的字段，例如 &lt;code&gt;relation&lt;/code&gt; 表示问题类型，&lt;code&gt;targets&lt;/code&gt; 表示目标实体或论文，&lt;code&gt;requested_fields&lt;/code&gt; 表示需要回答哪些信息，&lt;code&gt;required_modalities&lt;/code&gt; 表示需要什么证据类型，&lt;code&gt;answer_shape&lt;/code&gt; 表示答案形态，&lt;code&gt;precision_requirement&lt;/code&gt; 表示精确度要求。&lt;/p&gt;
&lt;p&gt;以最新真实 trace 中的 DPO 查询为例，用户输入是 &lt;code&gt;帮我看看 DPO 这篇论文的核心公式&lt;/code&gt;，系统得到的核心 contract 是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;clean_query&quot;: &quot;帮我看看 DPO 这篇论文的核心公式&quot;,
  &quot;interaction_mode&quot;: &quot;research&quot;,
  &quot;relation&quot;: &quot;formula_lookup&quot;,
  &quot;targets&quot;: [&quot;DPO&quot;],
  &quot;answer_slots&quot;: [&quot;formula&quot;],
  &quot;requested_fields&quot;: [&quot;formula&quot;, &quot;variable_explanation&quot;, &quot;source&quot;],
  &quot;required_modalities&quot;: [&quot;page_text&quot;, &quot;table&quot;],
  &quot;answer_shape&quot;: &quot;bullets&quot;,
  &quot;precision_requirement&quot;: &quot;exact&quot;,
  &quot;continuation_mode&quot;: &quot;fresh&quot;,
  &quot;allow_web_search&quot;: false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;relation=formula_lookup&lt;/code&gt; 决定了后续会走公式相关的检索和 solver；&lt;code&gt;requested_fields&lt;/code&gt; 要求答案必须包含公式、变量解释和来源；&lt;code&gt;precision_requirement=exact&lt;/code&gt; 表示系统不能只给概念性总结，而要尽量找到原文中的数学表达式。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;notes&lt;/code&gt; 是 &lt;code&gt;QueryContract&lt;/code&gt; 里的扩展记录区，用于保存结构化意图之外的运行痕迹。最新 LLM-judge 自动消歧后，真实 &lt;code&gt;notes&lt;/code&gt; 中会出现 &lt;code&gt;auto_resolved_by_llm_judge&lt;/code&gt;、&lt;code&gt;selected_paper_id=S6H9FE28&lt;/code&gt;、&lt;code&gt;disambiguation_judge_confidence=0.950&lt;/code&gt; 和 &lt;code&gt;disambiguation_judge_reason=...&lt;/code&gt;，说明系统不是让用户手动选择，而是在高置信度条件下自动绑定到了 DPO 原论文。&lt;/p&gt;
&lt;h3&gt;6.4 SessionContext&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SessionContext&lt;/code&gt; 负责保存多轮对话状态。它包含当前会话的 &lt;code&gt;session_id&lt;/code&gt;、最近研究主题、active research、历史 turn、工作记忆和 pending clarification 等信息。比如用户上一轮刚问过 DPO，下一轮追问“那这个公式里的 beta 是什么”，系统就需要通过 &lt;code&gt;SessionContext&lt;/code&gt; 知道“这个公式”指的是 DPO 论文中的公式，而不是重新把问题当成一个全新任务。&lt;/p&gt;
&lt;p&gt;这个模型里有一些 legacy 字段，例如 &lt;code&gt;active_targets&lt;/code&gt;、&lt;code&gt;active_titles&lt;/code&gt;、&lt;code&gt;active_research_relation&lt;/code&gt; 等，同时也有新的 &lt;code&gt;active_research&lt;/code&gt; 对象。&lt;code&gt;sync_active_research_compatibility()&lt;/code&gt; 的作用就是在旧字段和新结构之间做兼容同步，这是项目多次重构后留下的真实工程痕迹。&lt;/p&gt;
&lt;p&gt;在旧机制里，DPO 歧义会写入 &lt;code&gt;pending_clarification_options&lt;/code&gt;，然后等待用户下一轮选择。现在加入 LLM-judge 后，如果 judge 给出高置信度自动绑定，系统就不会进入 &lt;code&gt;needs_human=true&lt;/code&gt;；如果 judge 不够确定，仍然会保留人工澄清路径，并把推荐候选标记为 &lt;code&gt;judge_recommended&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;6.5 ResearchPlan&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ResearchPlan&lt;/code&gt; 是 Agent 对“怎么查”的计划。它不关心最终回答怎么写，而是规定论文召回模式、候选数量、证据数量、solver 顺序和 retry 预算。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ResearchPlan(BaseModel):
    paper_recall_mode: Literal[&quot;anchor_first&quot;, &quot;broad&quot;, &quot;broad_then_anchor&quot;] = &quot;broad&quot;
    paper_limit: int = 6
    evidence_limit: int = 14
    solver_sequence: list[str] = Field(default_factory=list)
    required_claims: list[str] = Field(default_factory=list)
    retry_budget: int = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DPO 公式查询的真实计划是 &lt;code&gt;paper_recall_mode=anchor_first&lt;/code&gt;、&lt;code&gt;paper_limit=6&lt;/code&gt;、&lt;code&gt;evidence_limit=24&lt;/code&gt;、&lt;code&gt;solver_sequence=[&quot;formula_solver&quot;, &quot;table_solver&quot;]&lt;/code&gt;、&lt;code&gt;required_claims=[&quot;formula&quot;, &quot;variable_explanation&quot;]&lt;/code&gt;。这说明系统会先围绕 DPO 这个 anchor 找论文，再在选中论文里扩展更多公式/表格相关证据。&lt;/p&gt;
&lt;h3&gt;6.6 CandidatePaper / DisambiguationJudgeDecision&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CandidatePaper&lt;/code&gt; 是论文级候选，表示“系统认为可能相关的一篇论文”。它包含 &lt;code&gt;paper_id&lt;/code&gt;、&lt;code&gt;title&lt;/code&gt;、&lt;code&gt;year&lt;/code&gt;、&lt;code&gt;score&lt;/code&gt;、&lt;code&gt;match_reason&lt;/code&gt;、&lt;code&gt;anchor_terms&lt;/code&gt;、&lt;code&gt;doc_ids&lt;/code&gt; 和原始 metadata。对于 DPO 这类缩写问题，单纯召回候选还不够，因为本地库里可能有很多论文都提到了 DPO。&lt;/p&gt;
&lt;p&gt;最新加入的 &lt;code&gt;DisambiguationJudgeDecision&lt;/code&gt; 就是为了解决这个问题。它是 LLM-judge 的结构化输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class DisambiguationRejectedOption(BaseModel):
    option_id: str = &quot;&quot;
    reason: str = &quot;&quot;

class DisambiguationJudgeDecision(BaseModel):
    decision: Literal[&quot;auto_resolve&quot;, &quot;ask_human&quot;] = &quot;ask_human&quot;
    selected_option_id: str | None = None
    selected_paper_id: str | None = None
    confidence: float = Field(default=0.0, ge=0.0, le=1.0)
    reason: str = &quot;&quot;
    rejected_options: list[DisambiguationRejectedOption] = Field(default_factory=list)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一步发生在 &lt;code&gt;_agent_solve_claims()&lt;/code&gt; 中。系统先根据 evidence 生成多个 ambiguity options，再调用 &lt;code&gt;_judge_disambiguation_options()&lt;/code&gt;，让模型根据用户 query、QueryContract、候选 title、snippet、paper summary 和 ranking signals 判断哪个候选最符合用户真实意图。&lt;/p&gt;
&lt;p&gt;这里有两个阈值很重要：&lt;code&gt;DISAMBIGUATION_AUTO_RESOLVE_THRESHOLD = 0.85&lt;/code&gt;，只有 judge 决策为 &lt;code&gt;auto_resolve&lt;/code&gt; 且置信度不低于 0.85，系统才会自动绑定候选；&lt;code&gt;DISAMBIGUATION_RECOMMEND_THRESHOLD = 0.65&lt;/code&gt;，如果置信度在推荐区间，系统只会把候选置顶并标记 &lt;code&gt;judge_recommended&lt;/code&gt;，仍然让用户确认。这样既减少了显然可判断场景下的打断，也保留了低置信度情况下的人工兜底。&lt;/p&gt;
&lt;h3&gt;6.7 Evidence / Claim / Verification / Citation&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;EvidenceBlock&lt;/code&gt; 是证据块，负责保存证据来自哪篇论文、哪一页、哪种 block、具体片段是什么。它让回答可以追溯到 PDF 页面，也让 citation preview 和 PDF preview 有了可定位的信息。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Claim&lt;/code&gt; 是从 evidence 中抽取出来的结论单元。比如公式查询中，claim 会保存公式文本、变量解释、证据 ids、paper ids 和置信度。这样答案生成不是直接从一堆文本片段自由发挥，而是先形成可检查的结构化结论。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VerificationReport&lt;/code&gt; 是 grounding 校验结果。它用 &lt;code&gt;status=pass|retry|clarify&lt;/code&gt; 表示当前 claim 是否足够可靠，&lt;code&gt;missing_fields&lt;/code&gt; 表示缺什么，&lt;code&gt;unsupported_claims&lt;/code&gt; 表示哪些结论没有证据支持，&lt;code&gt;recommended_action&lt;/code&gt; 表示下一步应该 retry、澄清还是继续回答。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AssistantCitation&lt;/code&gt; 和 &lt;code&gt;AssistantResponse&lt;/code&gt; 是 Agent 内部最终返回对象。&lt;code&gt;AssistantResponse&lt;/code&gt; 里不只有 &lt;code&gt;answer&lt;/code&gt;，还包括 &lt;code&gt;citations&lt;/code&gt;、&lt;code&gt;query_contract&lt;/code&gt;、&lt;code&gt;research_plan_summary&lt;/code&gt;、&lt;code&gt;runtime_summary&lt;/code&gt;、&lt;code&gt;execution_steps&lt;/code&gt;、&lt;code&gt;verification_report&lt;/code&gt;、&lt;code&gt;needs_human&lt;/code&gt; 和澄清字段。API 层随后再把它包装成 &lt;code&gt;AgentChatResponse&lt;/code&gt; 返回给前端。&lt;/p&gt;
&lt;h3&gt;6.8 真实流转案例&lt;/h3&gt;
&lt;p&gt;以下是 2026-05-02 的真实 DPO 公式查询 SSE 流式 trace，完整原始文件保存在 &lt;code&gt;docs/dpo_real_trace_20260502.txt&lt;/code&gt;。请求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;query&quot;: &quot;帮我看看 DPO 这篇论文的核心公式&quot;,
  &quot;mode&quot;: &quot;auto&quot;,
  &quot;use_web_search&quot;: false,
  &quot;max_web_results&quot;: 3,
  &quot;session_id&quot;: &quot;dpo-real-trace-20260502-010824&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本次流式响应共 60 个 SSE 事件，事件类型分布：&lt;code&gt;answer_delta: 21&lt;/code&gt;, &lt;code&gt;observation: 10&lt;/code&gt;, &lt;code&gt;thinking_delta: 6&lt;/code&gt;, &lt;code&gt;tool_call: 5&lt;/code&gt;, &lt;code&gt;agent_step: 3&lt;/code&gt;, &lt;code&gt;screened_papers: 2&lt;/code&gt;, &lt;code&gt;evidence: 2&lt;/code&gt;, 以及 &lt;code&gt;session&lt;/code&gt;, &lt;code&gt;contract&lt;/code&gt;, &lt;code&gt;agent_plan&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;candidate_papers&lt;/code&gt;, &lt;code&gt;solver_selection&lt;/code&gt;, &lt;code&gt;claims&lt;/code&gt;, &lt;code&gt;reflection&lt;/code&gt;, &lt;code&gt;verification&lt;/code&gt;, &lt;code&gt;confidence&lt;/code&gt;, &lt;code&gt;final&lt;/code&gt; 各 1 个。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关键事件快照：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;[event #1] &lt;code&gt;session&lt;/code&gt; — 生成 session_id=&lt;code&gt;dpo-real-trace-20260502-010824&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;[event #2] &lt;code&gt;contract&lt;/code&gt; — LLMIntentRouter 输出的结构化意图：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;clean_query&quot;: &quot;帮我看看 DPO 这篇论文的核心公式&quot;,
  &quot;interaction_mode&quot;: &quot;research&quot;,
  &quot;relation&quot;: &quot;formula_lookup&quot;,
  &quot;targets&quot;: [&quot;DPO&quot;, &quot;Direct Preference Optimization&quot;],
  &quot;answer_slots&quot;: [&quot;formula&quot;],
  &quot;requested_fields&quot;: [&quot;formula&quot;, &quot;variable_explanation&quot;, &quot;source&quot;],
  &quot;required_modalities&quot;: [&quot;page_text&quot;, &quot;table&quot;],
  &quot;answer_shape&quot;: &quot;bullets&quot;,
  &quot;precision_requirement&quot;: &quot;exact&quot;,
  &quot;continuation_mode&quot;: &quot;fresh&quot;,
  &quot;allow_web_search&quot;: false,
  &quot;notes&quot;: [&quot;structured_intent&quot;, &quot;llm_tool_router&quot;, &quot;intent_confidence=0.90&quot;,
            &quot;router_action=need_corpus_search&quot;, &quot;router_confidence=0.90&quot;, ...]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;[event #3] &lt;code&gt;agent_plan&lt;/code&gt; — AgentPlanner 的初始计划：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;thought&quot;: &quot;Use tools through the agent loop, observe the result, then compose or ask for clarification.&quot;,
  &quot;actions&quot;: [&quot;search_corpus&quot;],
  &quot;tool_call_args&quot;: [{&quot;name&quot;: &quot;search_corpus&quot;, &quot;args&quot;: {&quot;query&quot;: &quot;DPO Direct Preference Optimization core formula...&quot;, &quot;scope&quot;: &quot;auto&quot;, &quot;top_k&quot;: 8}}]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;[event #4] &lt;code&gt;plan&lt;/code&gt; — ResearchPlan：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;paper_recall_mode&quot;: &quot;anchor_first&quot;, &quot;paper_limit&quot;: 6, &quot;evidence_limit&quot;: 24,
 &quot;solver_sequence&quot;: [&quot;formula_solver&quot;], &quot;required_claims&quot;: [&quot;formula&quot;, &quot;variable_explanation&quot;], &quot;retry_budget&quot;: 1}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;[tool loop] 执行序列共 15 个 execution steps：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;query_contract_extractor&lt;/code&gt; → formula_lookup&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_planner&lt;/code&gt; → search_corpus&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:build_research_plan&lt;/code&gt; → formula_solver（内置工具，不暴露给 LLM planner）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_loop&lt;/code&gt; → search_corpus&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:search_corpus&lt;/code&gt; → candidates=6, selected=1&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:search_corpus&lt;/code&gt; → evidence=6（第一轮证据块）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:search_corpus&lt;/code&gt; → papers=1, evidence=6（筛选后）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:read_memory&lt;/code&gt; → turns=0（检查会话历史）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:compose&lt;/code&gt; → &lt;strong&gt;options=4, judge=auto_resolve, confidence=0.95&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:compose&lt;/code&gt; → claim_solver=deterministic&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:compose&lt;/code&gt; → claims=1&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:verify_claim&lt;/code&gt; → pass&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:compose&lt;/code&gt; → pass&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_reflection&lt;/code&gt; → pass&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tool:verify_claim&lt;/code&gt; → pass&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;[LLM-judge 消歧] 第 9 步 &lt;code&gt;observation&lt;/code&gt; 事件中的真实决策：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;tool&quot;: &quot;compose&quot;,
  &quot;summary&quot;: &quot;options=4, judge=auto_resolve, confidence=0.95&quot;,
  &quot;payload&quot;: {
    &quot;judge_decision&quot;: {
      &quot;decision&quot;: &quot;auto_resolve&quot;,
      &quot;selected_option_id&quot;: &quot;acronym-meaning-dpo-...&quot;,
      &quot;selected_paper_id&quot;: &quot;S6H9FE28&quot;,
      &quot;confidence&quot;: 0.95,
      &quot;reason&quot;: &quot;The query explicitly asks for the core formula of &apos;DPO,&apos; and the candidate titled &apos;Direct Preference Optimization: Your Language Model is Secretly a Reward Model&apos; directly defines and originates the concept of DPO.&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;[event #60] &lt;code&gt;final&lt;/code&gt; — 最终答案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;interaction_mode&lt;/code&gt;: research&lt;/li&gt;
&lt;li&gt;&lt;code&gt;answer&lt;/code&gt;: 574 字符，含 LaTeX 公式 $$\mathcal{L}&lt;em&gt;{\mathrm{DPO}} = -\log \sigma\left( \beta \log \frac{\pi&lt;/em&gt;{\theta}(y_w|x)}{\pi_{\mathrm{ref}}(y_w|x)} - \beta \log \frac{\pi_{\theta}(y_l|x)}{\pi_{\mathrm{ref}}(y_l|x)} \right)$$ 及变量解释&lt;/li&gt;
&lt;li&gt;&lt;code&gt;citations&lt;/code&gt;: 2 条（均来自 paper S6H9FE28，page 4）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verification_report.status&lt;/code&gt;: pass&lt;/li&gt;
&lt;li&gt;&lt;code&gt;needs_human&lt;/code&gt;: false&lt;/li&gt;
&lt;li&gt;&lt;code&gt;claim_sources&lt;/code&gt;: &lt;code&gt;{&quot;deterministic_formula_solver&quot;: 1}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;本次 trace 反映的链路口径：&lt;strong&gt;Intent Router（tool-calling LLM）→ Contract → Agent Plan（LLM planner）→ Research Plan（内置 resolver）→ Tool Loop（search_corpus → read_memory → compose/消歧 → compose/solver → verify_claim → reflection）→ Final&lt;/strong&gt;。相比旧版，新 trace 新增了 &lt;code&gt;solver_selection&lt;/code&gt;、&lt;code&gt;confidence&lt;/code&gt;、&lt;code&gt;reflection&lt;/code&gt;、&lt;code&gt;thinking_delta&lt;/code&gt;、&lt;code&gt;agent_step&lt;/code&gt; 事件类型，工具验证使用 &lt;code&gt;verify_claim&lt;/code&gt; 替代了旧版 &lt;code&gt;verify_grounding&lt;/code&gt;，增加了 &lt;code&gt;read_memory&lt;/code&gt; 工具在检索前检查会话历史。&lt;/p&gt;
&lt;h2&gt;7. 服务层拆解&lt;/h2&gt;
&lt;p&gt;服务层是整个项目最容易被深入追问的部分，因为它决定了这个系统是不是只是在“套一个聊天壳”，还是确实把论文库读取、PDF 抽取、索引构建、向量嵌入、混合检索、会话记忆和模型调用这些底层能力设计清楚了。API 层只是入口，Agent 层负责调度，真正支撑论文问答质量和效率的是服务层。&lt;/p&gt;
&lt;p&gt;从职责上看，服务层可以分成三条主线。第一条是离线入库链路：&lt;code&gt;ZoteroSQLiteReader&lt;/code&gt; 读取 Zotero 元信息，&lt;code&gt;PDFExtractor&lt;/code&gt; 抽取 PDF 页面文本、表格、图像和图注，&lt;code&gt;IngestionService&lt;/code&gt; 生成 paper docs 和 block docs，并写入 JSONL 与 Milvus。第二条是在线检索链路：&lt;code&gt;DualIndexRetriever&lt;/code&gt; 加载本地 paper/block 文档，使用 Milvus Dense 检索为主（BM25/Title Anchor 可选），先找候选论文，再找证据块。第三条是运行支撑链路：&lt;code&gt;SessionStore&lt;/code&gt; 负责多轮会话持久化，&lt;code&gt;ModelClients&lt;/code&gt; 统一封装 LLM/VLM/Embedding 调用，&lt;code&gt;WebSearch&lt;/code&gt; 在本地语料不足时提供外部补充。&lt;/p&gt;
&lt;h3&gt;7.0 服务层设计总览&lt;/h3&gt;
&lt;p&gt;这个项目的核心嵌入结构不是“把所有 PDF 切块后一起扔进向量库”，而是采用 paper index 和 block index 两级索引。paper index 面向论文级召回，保存的是 paper card，里面包含 title、aliases、authors、year、tags、abstract_or_summary 和 top_evidence_hints；block index 面向证据级召回，保存的是从 PDF 中抽出来的 page_text、table、figure、caption 等块，并保留 page、block_type、caption、bbox、formula_hint、paper_id、doc_id 等 metadata。&lt;/p&gt;
&lt;p&gt;这样设计是为了解决普通 PDF RAG 的两个问题。第一，如果直接在所有 PDF chunks 里检索，召回空间太大，噪声高，容易找到同名概念但不是目标论文的片段。第二，如果只做论文级检索，虽然能找到论文，但回答公式、指标、图表、实验结果时没有足够细的证据。两级索引的思路是先用 paper index 找“哪几篇论文可能相关”，再用 block index 在这些论文内部找“哪几个证据块可以支撑答案”。&lt;/p&gt;
&lt;p&gt;检索效果上，系统默认使用 Milvus Dense 向量检索作为主召回路径。BM25 适合处理标题、缩写、公式 token、作者名、年份这类精确匹配，保留为可选模块（接入 jieba 中文分词后 Hit@1 从 0.176 恢复到 0.748）。对于 DPO、PPO、PBA 这类缩写和公式问题，系统还会利用 title anchor、relation anchor、formula token weights、target terms、block_type 和 formula_hint 做额外加权，避免向量相似度把语义相关但不是目标论文的内容排到前面。&lt;/p&gt;
&lt;p&gt;其中 relation anchor 是最近从 stub 补完为完整实现的四路之一。它的设计思路是：如果用户明确提到了某个概念/方法名，那与已匹配论文共享标签、缩写词、作者或 Zotero 分类的其他论文也很可能相关——即使这些论文的标题里不含 query term。实现上，先由 title_anchor 确定锚点论文并提取其&quot;关系指纹&quot;（tags、aliases、body_acronyms、authors、collection paths），再遍历全库论文按共享信号数量打分排序。Zotero 分类路径从 SQLite 的 collections/collectionItems 表实时加载，缓存为内存字典；若 DB 不可用则自动降级，跳过 collection 信号。&lt;/p&gt;
&lt;p&gt;检索效率上，系统把重活尽量放在离线入库阶段。PDF 抽取、文本切块、summary 生成、embedding upsert 都在 &lt;code&gt;ingest rebuild&lt;/code&gt; 时完成；在线请求只加载已经持久化的 &lt;code&gt;papers.jsonl&lt;/code&gt;、&lt;code&gt;blocks.jsonl&lt;/code&gt; 和 Milvus collection。&lt;code&gt;DualIndexRetriever&lt;/code&gt; 被依赖注入缓存成长期对象，启动后构建 BM25，后续请求复用；重建索引后再调用 &lt;code&gt;refresh()&lt;/code&gt; 重新加载本地 JSONL 和 BM25。向量入库使用 batch upsert、retry 和 fallback embedding model，避免一次失败导致整个入库不可用。&lt;/p&gt;
&lt;h3&gt;7.1 Zotero 读取&lt;/h3&gt;
&lt;p&gt;Zotero 论文库的元信息全部来自 Zotero 本地的 &lt;code&gt;zotero.sqlite&lt;/code&gt; 数据库。&lt;code&gt;ZoteroSQLiteReader&lt;/code&gt;（&lt;code&gt;app/services/library/zotero_sqlite.py&lt;/code&gt;）是这个数据的唯一入口，负责读取论文记录、解析附件路径、构建 paper card 所需的 title/author/year/tags/abstract/collection 等字段。&lt;/p&gt;
&lt;p&gt;核心读取方法是 &lt;code&gt;read_records()&lt;/code&gt;，它会查询 Zotero SQLite 中的 items、collections、creators、itemAttachments、itemNotes 等表，按 itemType 筛选出期刊论文、会议论文、学位论文、书籍章节等类型，并组装成 &lt;code&gt;PaperRecord&lt;/code&gt; 数据结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class PaperRecord:
    parent_item_id: int
    attachment_item_id: int
    attachment_key: str       # Zotero attachment key，用作 paper_id
    item_type: str            # 如 &quot;journalArticle&quot;, &quot;conferencePaper&quot;
    title: str
    authors: list[str]
    year: str
    tags: list[str]
    abstract_note: str
    source_url: str
    website_title: str
    file_path: str            # 解析后的本地 PDF 绝对路径
    file_exists: bool         # PDF 是否实际存在
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;附件的 PDF 路径解析是 reader 的核心——通过 &lt;code&gt;ATTACHMENT_SQL&lt;/code&gt; 查询 &lt;code&gt;itemAttachments&lt;/code&gt; 表，利用 &lt;code&gt;parentItemID&lt;/code&gt; 关联父条目、&lt;code&gt;contentType=&apos;application/pdf&apos;&lt;/code&gt; 过滤 PDF 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ATTACHMENT_SQL = &quot;&quot;&quot;
SELECT
    ia.parentItemID AS parent_item_id,
    ia.itemID AS attachment_item_id,
    ia.path AS attachment_path,
    ia.contentType AS content_type,
    parent.key AS parent_key,
    attachment.key AS attachment_key,
    parent_type.typeName AS parent_item_type
FROM itemAttachments ia
JOIN items parent ON parent.itemID = ia.parentItemID
JOIN items attachment ON attachment.itemID = ia.itemID
JOIN itemTypes parent_type ON parent_type.itemTypeID = parent.itemTypeID
WHERE ia.parentItemID IS NOT NULL
  AND ia.path IS NOT NULL
  AND lower(ia.contentType) = &apos;application/pdf&apos;
ORDER BY ia.parentItemID
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Zotero SQLite 的 schema 中，items 和 collections 通过 &lt;code&gt;collectionItems&lt;/code&gt; 做多对多映射，creators 通过 &lt;code&gt;itemCreators&lt;/code&gt; 关联（creatorData 和 creators 表有两个查询路径），附件通过 &lt;code&gt;itemAttachments.parentItemID&lt;/code&gt; 关联到父条目的 &lt;code&gt;itemID&lt;/code&gt;（注意 Zotero 用 &lt;code&gt;itemID&lt;/code&gt; 而非 &lt;code&gt;id&lt;/code&gt;）。&lt;code&gt;ZoteroSQLiteReader&lt;/code&gt; 封装了这些查询细节，上层模块不需要直接写 SQL。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;read_attachment_collection_paths()&lt;/code&gt; 方法专门为前端侧栏服务，返回 &lt;code&gt;{attachment_key: [collection_path, ...]}&lt;/code&gt; 映射，在 &lt;code&gt;LibraryBrowserService.list_library()&lt;/code&gt; 中被用来按 collection 分组展示论文库。&lt;/p&gt;
&lt;h3&gt;7.2 PDF 抽取&lt;/h3&gt;
&lt;p&gt;PDF 抽取由 &lt;code&gt;PDFExtractor&lt;/code&gt;（&lt;code&gt;app/services/retrieval/pdf_extractor.py&lt;/code&gt;）完成。它基于 &lt;code&gt;pypdf&lt;/code&gt; 提取页面文本，并通过启发式信号识别页面中的表格、图表、扫描页等特殊区域。&lt;/p&gt;
&lt;p&gt;核心数据结构是三个 dataclass：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TABLE_LIKE_THRESHOLD = 2.5
FIGURE_LIKE_THRESHOLD = 2.5
SCANNED_LIKE_THRESHOLD = 2.0
MAX_HI_RES_PAGES_PER_DOC = 6

@dataclass(slots=True)
class PageSignals:
    caption_anchor_count: int = 0        # &quot;Table 1&quot;, &quot;Fig. 2&quot;, &quot;表 1&quot;, &quot;图 2&quot;
    table_anchor_count: int = 0
    figure_anchor_count: int = 0
    numeric_density: float = 0.0         # 数值 token 占比
    short_line_ratio: float = 0.0        # 短行比例
    avg_tokens_per_line: float = 0.0
    separator_pattern_score: float = 0.0 # 表格分隔符模式
    text_chars: int = 0
    image_object_count: int = 0
    table_like_score: float = 0.0
    figure_like_score: float = 0.0
    scanned_like_score: float = 0.0
    selected_reasons: tuple[str, ...] = ()

@dataclass(slots=True)
class ExtractedBlock:
    page: int
    block_type: str          # &quot;page_text&quot;, &quot;table&quot;, &quot;figure&quot;, &quot;caption&quot;
    text: str
    bbox: tuple[float, float, float, float] | None = None
    caption: str = &quot;&quot;
    source_parser: str = &quot;hi_res&quot;

@dataclass(slots=True)
class ExtractedPage:
    page: int
    text: str
    blocks: list[ExtractedBlock] = field(default_factory=list)
    signals: PageSignals = field(default_factory=PageSignals)
    selected_for_hi_res: bool = False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;抽取流程分两步：先用 &lt;code&gt;pypdf.PdfReader&lt;/code&gt; 逐页提取文本并计算 &lt;code&gt;PageSignals&lt;/code&gt;，再用这些信号分类每个页面。&lt;code&gt;PDFExtractor.extract_pages()&lt;/code&gt; 的主流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def extract_pages(self, pdf_path: Path) -&amp;gt; list[ExtractedPage]:
    reader = PdfReader(str(pdf_path))
    pages = self._extract_pages_with_pypdf(reader)          # Step 1: 文本 + 信号
    selected_pages = self._select_hi_res_pages(pages)        # 选出前 N 页做高分辨率
    if selected_pages and self.prefer_unstructured:
        hi_res_blocks = self._extract_selected_hi_res_blocks(pdf_path, reader, selected_pages)
        for page in pages:                                   # Step 2: 合并 hi_res 块
            page.selected_for_hi_res = page.page in selected_pages
            page.blocks = hi_res_blocks.get(page.page, [])
    return pages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_select_hi_res_pages()&lt;/code&gt; 根据 &lt;code&gt;PageSignals&lt;/code&gt; 的 &lt;code&gt;table_like_score&lt;/code&gt;、&lt;code&gt;figure_like_score&lt;/code&gt; 决定哪些页面值得做高分辨率抽取，最多选 &lt;code&gt;MAX_HI_RES_PAGES_PER_DOC = 6&lt;/code&gt; 页。对于扫描版 PDF（&lt;code&gt;scanned_like_score &amp;gt; 2.0&lt;/code&gt;），后续可通过 &lt;code&gt;pdf_rendering.py&lt;/code&gt; 渲染为图片再由 VLM 理解。&lt;/p&gt;
&lt;h3&gt;7.3 Ingestion 入库&lt;/h3&gt;
&lt;p&gt;入库流程由 &lt;code&gt;IngestionService&lt;/code&gt;（&lt;code&gt;app/services/retrieval/indexing.py&lt;/code&gt;）统一编排。初始化时组装好 reader、extractor、splitter 三大组件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class IngestionService:
    def __init__(self, settings: Settings, clients: ModelClients | None = None) -&amp;gt; None:
        self.settings = settings
        self.clients = clients or ModelClients(settings)
        self.reader = ZoteroSQLiteReader(settings)
        self.extractor = PDFExtractor(settings=settings, prefer_unstructured=True)
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=800,
            chunk_overlap=120,
            separators=[&quot;\n\n&quot;, &quot;\n&quot;, &quot;。&quot;, &quot;. &quot;, &quot; &quot;, &quot;&quot;],
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;rebuild()&lt;/code&gt; 方法执行完整的离线入库链路：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def rebuild(self, *, max_papers=None, force_rebuild=True) -&amp;gt; IngestionStats:
    records = self.reader.read_records(max_papers=max_papers)
    stats = IngestionStats(paper_records=len(records))
    paper_docs, block_docs = [], []
    state: dict[str, Any] = {&quot;papers&quot;: {}}

    for record in records:
        paper_id = record.attachment_key
        if not record.file_exists:
            stats.papers_missing_pdf += 1
            continue
        pages = self.extractor.extract_pages(Path(record.file_path))
        # 生成 paper card (含 LLM 摘要) 和 block documents
        paper_doc, generated = self._build_paper_card(record=record, pages=pages)
        paper_docs.append(paper_doc)
        block_docs.extend(self._build_block_documents(record=record, pages=pages))
        stats.papers_indexed += 1
        state[&quot;papers&quot;][paper_id] = {...}

    # 持久化到 JSONL
    self._persist_jsonl(self.settings.paper_store_path, paper_docs)
    self._persist_jsonl(self.settings.block_store_path, block_docs)
    # 向量化写入 Milvus
    vectors_upserted = self._upsert_vectors(paper_docs, block_docs, force_rebuild)
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;入库的核心产出是两类 LangChain &lt;code&gt;Document&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;paper docs&lt;/strong&gt;：论文级索引，page_content 含 title/abstract/summary，metadata 含 &lt;code&gt;paper_id&lt;/code&gt;、&lt;code&gt;doc_id&lt;/code&gt;、&lt;code&gt;title&lt;/code&gt;、&lt;code&gt;authors&lt;/code&gt;、&lt;code&gt;year&lt;/code&gt;、&lt;code&gt;tags&lt;/code&gt;、&lt;code&gt;top_evidence_hints&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;block docs&lt;/strong&gt;：证据级索引，来源是 &lt;code&gt;ExtractedBlock&lt;/code&gt;，metadata 含 &lt;code&gt;doc_id&lt;/code&gt;（SHA1 哈希）、&lt;code&gt;paper_id&lt;/code&gt;、&lt;code&gt;page&lt;/code&gt;、&lt;code&gt;block_type&lt;/code&gt;、&lt;code&gt;caption&lt;/code&gt;、&lt;code&gt;bbox&lt;/code&gt;、&lt;code&gt;formula_hint&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;FORMULA_HINT_RE&lt;/code&gt; 会对包含 π、β、sigma、loss、objective、reward 等 token 的页面标记 &lt;code&gt;formula_hint&lt;/code&gt;，供检索加权使用。向量写入 Milvus 时先尝试 &lt;code&gt;text-embedding-3-large&lt;/code&gt;，失败自动降级到 &lt;code&gt;text-embedding-3-small&lt;/code&gt;，batch upsert 默认每批 128 条带重试（可通过 &lt;code&gt;upsert_batch_size&lt;/code&gt; 配置）。&lt;/p&gt;
&lt;h3&gt;7.4 Retriever 双索引检索&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;DualIndexRetriever&lt;/code&gt;（&lt;code&gt;app/services/retrieval/core.py&lt;/code&gt;）是在线检索的核心。初始化时加载本地 JSONL 并构建双路索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class DualIndexRetriever:
    def __init__(self, settings: Settings) -&amp;gt; None:
        self.settings = settings
        self._paper_docs: list[Document] = []
        self._block_docs: list[Document] = []
        self._load_library_docs()
        self._paper_bm25 = self._build_bm25(self._paper_docs, settings.paper_bm25_top_k)
        self._block_bm25 = self._build_bm25(self._block_docs, settings.block_bm25_top_k)
        self._paper_dense = CollectionVectorIndex(settings, collection_name=settings.milvus_paper_collection)
        self._block_dense = CollectionVectorIndex(settings, collection_name=settings.milvus_block_collection)

    def refresh(self) -&amp;gt; None:
        &quot;&quot;&quot;ingest rebuild 后重新加载 JSONL 和重建 BM25，无需重启服务&quot;&quot;&quot;
        self._load_library_docs()
        self._paper_bm25 = self._build_bm25(self._paper_docs, self.settings.paper_bm25_top_k)
        self._block_bm25 = self._build_bm25(self._block_docs, self.settings.block_bm25_top_k)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;论文级检索 &lt;code&gt;search_papers()&lt;/code&gt; 的核心逻辑——多路加权融合：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def search_papers(self, *, query, contract, limit=None) -&amp;gt; list[CandidatePaper]:
    search_text = query.strip()
    target_terms = self._contract_target_terms(contract)
    # 用 targets 扩展检索词
    if target_text and target_text.lower() not in search_text.lower():
        search_text = f&quot;{target_text} {search_text}&quot;.strip()

    weighted_docs: list[tuple[float, list[Document]]] = []
    # title anchor 精确匹配 (权重 1.6)
    anchors = self.title_anchor(target_terms)
    if anchors: weighted_docs.append((1.6, anchors))
    # relation anchor 关系锚定 (权重 1.3)
    relation_anchors = self.relation_anchor_docs(contract)
    if relation_anchors: weighted_docs.append((1.3, relation_anchors))
    # BM25 稀疏检索 (权重 0.9)
    if self._paper_bm25: weighted_docs.append((0.9, self._paper_bm25.invoke(search_text)))
    # Milvus dense 检索 (权重 0.8)
    dense_docs = self._paper_dense.search_documents(search_text, limit=...)
    if dense_docs: weighted_docs.append((0.8, dense_docs))
    # Weighted RRF 融合 + paper_match_boost 加权 → CandidatePaper 列表
    fused = self._rrf_fuse(weighted_docs)
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;四路召回最初设计为 Weighted RRF 多路融合（title anchor 1.6 / relation anchor 1.3 / BM25 0.9 / dense 0.8）。经过 159 题 × 12 配置消融实验，Pure Dense + paper_query_text QE 在所有条件下均最优（Hit@1=97.5%），多路融合不如 Dense 且慢 6 倍。当前默认使用 Dense-only 检索，BM25/Title Anchor/Relation Anchor 保留为可选模块。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;relation_anchor_docs 的完整实现&lt;/strong&gt;（&lt;code&gt;core.py:690-770&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def relation_anchor_docs(self, contract: QueryContract) -&amp;gt; list[Document]:
    target_terms = self._contract_target_terms(contract)
    if not target_terms:
        return []

    # 第一步：用 title_anchor 找出锚点论文
    anchors = self.title_anchor(target_terms)
    if not anchors:
        return []

    # 第二步：从锚点论文提取&quot;关系指纹&quot;
    anchor_tags: set[str] = set()
    anchor_acronyms: set[str] = set()
    anchor_authors: set[str] = set()
    anchor_collections: set[str] = set()
    anchor_ids: set[str] = set()

    for doc in anchors:
        meta = doc.metadata or {}
        anchor_ids.add(str(meta.get(&quot;paper_id&quot;, &quot;&quot;)))
        # 标签（|| 分隔）
        for tag in str(meta.get(&quot;tags&quot;, &quot;&quot;)).split(&quot;||&quot;):
            tag = tag.strip().lower()
            if tag: anchor_tags.add(tag)
        # 缩写词（从 aliases 和 body_acronyms 提取）
        for field in (&quot;aliases&quot;, &quot;body_acronyms&quot;):
            for item in str(meta.get(field, &quot;&quot;)).split(&quot;||&quot;):
                item = item.strip().lower()
                if item and len(item) &amp;gt;= 2:
                    anchor_acronyms.add(item)
        # 作者（逗号分隔）
        for author in str(meta.get(&quot;authors&quot;, &quot;&quot;)).split(&quot;,&quot;):
            author = author.strip().lower()
            if author: anchor_authors.add(author)
        # Zotero 分类路径（从预加载的 _collection_paths 字典查询）
        paper_id = str(meta.get(&quot;paper_id&quot;, &quot;&quot;))
        for coll_path in self._collection_paths.get(paper_id, []):
            anchor_collections.add(coll_path.lower())

    # 第三步：遍历所有非锚点论文，按共享信号打分
    scored: list[tuple[float, Document]] = []
    for doc in self._paper_docs:
        meta = doc.metadata or {}
        paper_id = str(meta.get(&quot;paper_id&quot;, &quot;&quot;))
        if paper_id in anchor_ids:
            continue  # 跳过锚点本身（title anchor 已召回）

        score = 0.0
        # 共享标签：+1.8 / 个
        doc_tags = {t.strip().lower() for t in
                    str(meta.get(&quot;tags&quot;, &quot;&quot;)).split(&quot;||&quot;) if t.strip()}
        shared_tags = anchor_tags &amp;amp; doc_tags
        if shared_tags: score += len(shared_tags) * 1.8
        # 共享缩写词：+1.0 / 个（上限 5 个，防止高频缩写词噪声）
        doc_acronyms: set[str] = set()
        for field in (&quot;aliases&quot;, &quot;body_acronyms&quot;):
            for item in str(meta.get(field, &quot;&quot;)).split(&quot;||&quot;):
                item = item.strip().lower()
                if item and len(item) &amp;gt;= 2:
                    doc_acronyms.add(item)
        shared_acronyms = anchor_acronyms &amp;amp; doc_acronyms
        if shared_acronyms:
            score += min(len(shared_acronyms), 5) * 1.0
        # 共享作者：+1.2 / 个
        doc_authors = {a.strip().lower() for a in
                       str(meta.get(&quot;authors&quot;, &quot;&quot;)).split(&quot;,&quot;) if a.strip()}
        shared_authors = anchor_authors &amp;amp; doc_authors
        if shared_authors: score += len(shared_authors) * 1.2
        # 共享 Zotero 分类路径：+2.5 / 个（最强信号）
        doc_collections = set()
        for coll_path in self._collection_paths.get(paper_id, []):
            doc_collections.add(coll_path.lower())
        shared_collections = anchor_collections &amp;amp; doc_collections
        if shared_collections:
            score += len(shared_collections) * 2.5

        if score &amp;gt; 0:
            meta[&quot;relation_score&quot;] = score
            scored.append((score, doc))

    if not scored:
        return []
    scored.sort(key=lambda x: x[0], reverse=True)
    return [doc for _, doc in scored[:self.settings.paper_bm25_top_k]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;四个关系信号的权重设计依据：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;信号&lt;/th&gt;
&lt;th&gt;权重&lt;/th&gt;
&lt;th&gt;数据来源&lt;/th&gt;
&lt;th&gt;设计理由&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Zotero 分类&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_collection_paths&lt;/code&gt; 字典（检索器初始化时从 zotero.sqlite 加载）&lt;/td&gt;
&lt;td&gt;用户手动整理的分类结构，同一分类的论文天然强相关&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;共享标签&lt;/td&gt;
&lt;td&gt;1.8&lt;/td&gt;
&lt;td&gt;paper_card metadata &lt;code&gt;tags&lt;/code&gt; 字段&lt;/td&gt;
&lt;td&gt;用户标记的结构化知识，置信度高&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;共享作者&lt;/td&gt;
&lt;td&gt;1.2&lt;/td&gt;
&lt;td&gt;paper_card metadata &lt;code&gt;authors&lt;/code&gt; 字段&lt;/td&gt;
&lt;td&gt;弱信号——同一作者可能跨领域发表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;共享缩写词&lt;/td&gt;
&lt;td&gt;1.0（≤5）&lt;/td&gt;
&lt;td&gt;paper_card metadata &lt;code&gt;aliases&lt;/code&gt; + &lt;code&gt;body_acronyms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;最弱信号——PPO/RLHF 等高频缩写词易引入噪声，加 5 个上限&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Zotero 分类数据通过 &lt;code&gt;_load_collections()&lt;/code&gt; 在 &lt;code&gt;DualIndexRetriever.__init__&lt;/code&gt; 时调用 &lt;code&gt;ZoteroSQLiteReader.read_attachment_collection_paths()&lt;/code&gt; 加载，返回 &lt;code&gt;dict[attachment_key, list[collection_path_string]]&lt;/code&gt;。若 Zotero SQLite 不可用（非本地环境），静默降级为空字典，collection 信号自动跳过，其余三个信号继续生效。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RETRIEVAL_MARKERS&lt;/code&gt; 字典定义了场景化检索标记：公式场景加重 &quot;objective&quot;、&quot;formula&quot;、&quot;公式&quot; 等词；机制场景加重 &quot;workflow&quot;、&quot;mechanism&quot; 等词。&lt;code&gt;BOOK_ITEM_TYPES&lt;/code&gt; 和 &lt;code&gt;BOOKISH_TITLE_MARKERS&lt;/code&gt;（&quot;实战&quot;、&quot;教程&quot;、&quot;指南&quot;等）用于识别和降权书籍类条目。&lt;/p&gt;
&lt;h3&gt;7.5 Milvus 向量索引&lt;/h3&gt;
&lt;p&gt;向量索引由 &lt;code&gt;CollectionVectorIndex&lt;/code&gt;（&lt;code&gt;app/services/retrieval/vector_index.py&lt;/code&gt;）封装，底层对接 Milvus（本地部署 &lt;code&gt;http://localhost:19530&lt;/code&gt;）。系统维护两个 collection：&lt;code&gt;zprag_papers&lt;/code&gt; 和 &lt;code&gt;zprag_blocks&lt;/code&gt;。Embedding 使用独立的 &lt;code&gt;embedding_api_key&lt;/code&gt; + &lt;code&gt;embedding_base_url&lt;/code&gt;（fallback 到 &lt;code&gt;openai_api_key&lt;/code&gt;），当前部署通过 Qihai 网关调用 &lt;code&gt;text-embedding-3-large&lt;/code&gt;（3072 维），失败自动降级到 &lt;code&gt;text-embedding-3-small&lt;/code&gt;（1536 维）。&lt;/p&gt;
&lt;p&gt;配置项（&lt;code&gt;app/core/config.py&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;milvus_uri: str = &quot;http://localhost:19530&quot;
milvus_paper_collection: str = &quot;zprag_papers&quot;
milvus_block_collection: str = &quot;zprag_blocks&quot;
embedding_model: str = &quot;text-embedding-3-large&quot;        # 3072 维
embedding_fallback_model: str = &quot;text-embedding-3-small&quot;  # 1536 维
embedding_request_timeout_seconds: float = 120.0
embedding_batch_retry_attempts: int = 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;向量生成通过 &lt;code&gt;ModelClients&lt;/code&gt; 提供的 HTTP client 直接调用 embedding API，不走 LangChain 包装——以便精细控制 batch size、超时和重试。&lt;code&gt;CollectionVectorIndex.upsert_documents()&lt;/code&gt; 分批写入（默认 batch_size=128），遇网络错误自动重试；&lt;code&gt;search()&lt;/code&gt; 返回 top_k 结果含 id/score/metadata。&lt;/p&gt;
&lt;h3&gt;7.6 SessionStore&lt;/h3&gt;
&lt;p&gt;两个实现：&lt;code&gt;InMemorySessionStore&lt;/code&gt;（测试/开发用 dict 存储）和 &lt;code&gt;SQLiteSessionStore&lt;/code&gt;（生产用）。&lt;code&gt;data/v4_sessions.sqlite3&lt;/code&gt; 存储 session 数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class SQLiteSessionStore:
    def __init__(self, db_path: str | Path, max_turns: int = 8) -&amp;gt; None:
        self.db_path = Path(db_path)
        self.max_turns = max(1, max_turns)
        self._lock = threading.RLock()
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._init_db()

    def get(self, session_id: str) -&amp;gt; SessionContext:
        with self._lock:
            row = conn.execute(
                &quot;SELECT context_json FROM sessions WHERE session_id = ?&quot;, (session_id,)
            ).fetchone()
            if row is None:
                return SessionContext(session_id=session_id)  # 首次访问创建新上下文
            return SessionContext.model_validate_json(row[&quot;context_json&quot;])

    def upsert(self, context: SessionContext) -&amp;gt; None:
        context = _trim_context_history(context, max_turns=self.max_turns)
        payload = context.model_dump_json()
        conn.execute(
            &quot;&quot;&quot;INSERT INTO sessions(session_id, context_json, updated_at)
               VALUES(?, ?, strftime(&apos;%Y-%m-%dT%H:%M:%fZ&apos;, &apos;now&apos;))
               ON CONFLICT(session_id) DO UPDATE SET ...&quot;&quot;&quot;,
            (context.session_id, payload),
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心操作：&lt;code&gt;get(session_id)&lt;/code&gt; → 获取/创建上下文；&lt;code&gt;upsert(context)&lt;/code&gt; → 保存并裁剪历史；&lt;code&gt;append_turn(session_id, turn)&lt;/code&gt; → 追加一轮对话。每次 upsert 时 &lt;code&gt;_trim_context_history()&lt;/code&gt; 裁剪超过 &lt;code&gt;max_turns&lt;/code&gt; 的旧轮次（构造函数默认 8，生产环境通过 &lt;code&gt;deps.py&lt;/code&gt; 传入 &lt;code&gt;agent_history_max_turns=24&lt;/code&gt;），压缩为摘要存入 &lt;code&gt;summary&lt;/code&gt; 字段。序列化通过 Pydantic &lt;code&gt;model_dump_json()&lt;/code&gt; / &lt;code&gt;model_validate_json()&lt;/code&gt;。&lt;code&gt;sync_active_research_compatibility()&lt;/code&gt; 负责 legacy 字段与新版 &lt;code&gt;active_research&lt;/code&gt; 之间的兼容同步。&lt;/p&gt;
&lt;h3&gt;7.7 ModelClients&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ModelClients&lt;/code&gt;（&lt;code&gt;app/services/infra/model_clients.py&lt;/code&gt;）是项目中所有大模型调用的统一封装层。惰性初始化，只在首次访问时创建连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ModelClients:
    def __init__(self, settings: Settings) -&amp;gt; None:
        self.settings = settings
        self._chat: ChatOpenAI | None = None
        self._vlm: ChatOpenAI | None = None
        self._http_client: httpx.Client | None = None
        self._async_http_client: httpx.AsyncClient | None = None

    @property
    def chat(self) -&amp;gt; ChatOpenAI | None:
        if not self.settings.openai_api_key: return None
        if self._chat is None:
            self._chat = ChatOpenAI(
                model=self.settings.chat_model,         # 当前部署: deepseek-v4-flash (默认: gpt-4o-mini)
                api_key=self.settings.openai_api_key,
                base_url=self.settings.openai_base_url,  # 当前: api.deepseek.com/v1
                temperature=0.1,
                max_tokens=self.settings.chat_max_tokens,  # 默认 1800
                http_client=self.http_client,
            )
        return self._chat

    @property
    def vlm(self) -&amp;gt; ChatOpenAI | None:
        if not self.settings.openai_api_key or not self.settings.enable_figure_vlm:
            return None
        if self._vlm is None:
            self._vlm = ChatOpenAI(
                model=self.settings.vlm_model,           # gpt-4.1-mini
                temperature=0.0,
                max_tokens=self.settings.chat_max_tokens,
                ...
            )
        return self._vlm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个模型能力（均通过 OpenAI 兼容 API 调用，当前部署使用不同 provider）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;chat&lt;/strong&gt;：意图识别、工具规划、claim 提取、验证、答案生成。当前部署 &lt;code&gt;deepseek-v4-flash&lt;/code&gt;（配置项 &lt;code&gt;chat_model&lt;/code&gt;，默认 &lt;code&gt;gpt-4o-mini&lt;/code&gt;），temperature=0.1，max_tokens=1800&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;vlm&lt;/strong&gt;：仅 &lt;code&gt;enable_figure_vlm=True&lt;/code&gt; 时初始化，用于图表理解。当前部署 &lt;code&gt;gpt-4.1-mini&lt;/code&gt;（配置项 &lt;code&gt;vlm_model&lt;/code&gt;），temperature=0.0&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;embedding&lt;/strong&gt;：通过 &lt;code&gt;http_client&lt;/code&gt; / &lt;code&gt;async_http_client&lt;/code&gt; 调用独立的 embedding API（&lt;code&gt;embedding_api_key&lt;/code&gt; + &lt;code&gt;embedding_base_url&lt;/code&gt;，fallback 到 &lt;code&gt;openai_api_key&lt;/code&gt;）。当前部署 &lt;code&gt;text-embedding-3-large&lt;/code&gt;（3072 维），通过 Qihai 网关&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;close()&lt;/code&gt; / &lt;code&gt;aclose()&lt;/code&gt; 在 lifespan 关闭阶段释放连接池。&lt;code&gt;invoke_json_messages()&lt;/code&gt; 和 &lt;code&gt;invoke_tool_plan_messages()&lt;/code&gt; 封装了&quot;发送 → 解析 JSON → fallback&quot;流程，遇解析失败返回 fallback 而非抛异常。&lt;/p&gt;
&lt;h3&gt;7.8 WebSearch&lt;/h3&gt;
&lt;p&gt;Web Search 由 &lt;code&gt;TavilyWebSearchClient&lt;/code&gt;（&lt;code&gt;app/services/retrieval/web_search.py&lt;/code&gt;）提供，对接 Tavily Search API。它的 &lt;code&gt;search()&lt;/code&gt; 方法接受 query、max_results、search_depth 等参数，返回带标题、URL、摘要和原始内容的搜索结果列表。&lt;/p&gt;
&lt;p&gt;在 Agent 主链路中，Web Search 不是默认开启的——用户需要在前端勾选&quot;使用 Web 搜索&quot;或请求中包含 &lt;code&gt;use_web_search=true&lt;/code&gt;。当本地语料检索后证据不足时，Agent 会自动判断是否需要补充 Web 证据，调用 &lt;code&gt;web_search&lt;/code&gt; 工具获取外部信息，再经过 &lt;code&gt;build_web_research_claim()&lt;/code&gt; 把网页内容转化为与本地证据格式兼容的 claim。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;collect_web_evidence()&lt;/code&gt; 函数负责完整的 Web 证据收集流程：搜索 → 获取页面内容 → 提取相关片段 → 生成带引用的 claim。Web 证据的引用格式与本地 PDF 证据统一，但在 citation 中会标记 &lt;code&gt;source_type=web&lt;/code&gt;，前端展示时能区分来源是本论文库还是外部网页。&lt;/p&gt;
&lt;h3&gt;7.9 意图识别（intents/）&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;intents/&lt;/code&gt; 子包（10 模块）负责将用户自然语言问题转化为结构化意图，是整个 Agent 链路的入口认知层。&lt;/p&gt;
&lt;p&gt;核心是 &lt;code&gt;LLMIntentRouter&lt;/code&gt;（&lt;code&gt;router.py&lt;/code&gt;），使用 tool-calling 模式而非传统文本分类——向 Chat Model 提供 5 个 tool choice，让模型根据语义选择最合适的动作：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RouterAction = Literal[&quot;answer_directly&quot;, &quot;need_conversation_tool&quot;,
                       &quot;need_corpus_search&quot;, &quot;need_web&quot;, &quot;need_clarify&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;路由输出不是只有动作标签，而是包含 &lt;code&gt;relation&lt;/code&gt;（20+ 种：&lt;code&gt;formula_lookup&lt;/code&gt;、&lt;code&gt;paper_summary_results&lt;/code&gt;、&lt;code&gt;entity_definition&lt;/code&gt;、&lt;code&gt;metric_value_lookup&lt;/code&gt;、&lt;code&gt;origin_lookup&lt;/code&gt; 等）、&lt;code&gt;targets&lt;/code&gt;、&lt;code&gt;requested_fields&lt;/code&gt;、&lt;code&gt;confidence&lt;/code&gt;、&lt;code&gt;continuation_mode&lt;/code&gt; 的完整结构。&lt;/p&gt;
&lt;p&gt;各模块按问题类型分工：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;research.py&lt;/code&gt; — 研究类问题的 answer slot 推断（&lt;code&gt;research_answer_slots()&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conversation.py&lt;/code&gt; — 对话类意图（library_status, library_recommendation, memory_followup 等）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;library.py&lt;/code&gt; — 论文库查询意图&lt;/li&gt;
&lt;li&gt;&lt;code&gt;figure.py&lt;/code&gt; — 图表类意图信号检测（&lt;code&gt;figure_signal_score()&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;followup.py&lt;/code&gt; / &lt;code&gt;followup_relationship.py&lt;/code&gt; — 追问意图与关系判断&lt;/li&gt;
&lt;li&gt;&lt;code&gt;memory.py&lt;/code&gt; — 记忆类意图&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contract_adapter.py&lt;/code&gt; — 在 answer_slots 和 research relation/requirements 之间做双向转换&lt;/li&gt;
&lt;li&gt;&lt;code&gt;marker_matching.py&lt;/code&gt; — &lt;code&gt;MarkerProfile&lt;/code&gt; 机制，用关键词配置文件匹配用户问题特征&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;配置文件 &lt;code&gt;intent_marker_profiles.json&lt;/code&gt; 定义了各类问题的标记词（如 DPO、PPO、PBA 等缩写属于 &lt;code&gt;acronym&lt;/code&gt; 类 marker）。&lt;/p&gt;
&lt;h3&gt;7.10 规划与合约（planning/ + contracts/）&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;planning/（7 模块）&lt;/strong&gt; 负责将意图转化为可执行的研究计划：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;research.py&lt;/code&gt; — &lt;code&gt;build_research_plan()&lt;/code&gt;：从 &lt;code&gt;QueryContract&lt;/code&gt; 生成 &lt;code&gt;ResearchPlan&lt;/code&gt;（召回模式、evidence 数量、solver 顺序）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;query_shaping.py&lt;/code&gt; — &lt;code&gt;query_target_candidates()&lt;/code&gt;：从用户问题中提取目标实体和论文缩写&lt;/li&gt;
&lt;li&gt;&lt;code&gt;query_rewrite.py&lt;/code&gt; — &lt;code&gt;rewrite_query()&lt;/code&gt;：多查询改写（multi_query / hyde / step_back），为检索生成多个角度的查询&lt;/li&gt;
&lt;li&gt;&lt;code&gt;solver_dispatch.py&lt;/code&gt; / &lt;code&gt;solver_goals.py&lt;/code&gt; — 决定哪些 solver 需要执行，以及各自的目标&lt;/li&gt;
&lt;li&gt;&lt;code&gt;compound_tasks.py&lt;/code&gt; — 复合查询分解与合并&lt;/li&gt;
&lt;li&gt;&lt;code&gt;schema_claims.py&lt;/code&gt; — 判断是否应该使用 schema-based claim solver&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;contracts/（8 模块）&lt;/strong&gt; 负责管理会话级别的上下文和约束：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;session_context.py&lt;/code&gt; — &lt;code&gt;agent_session_conversation_context()&lt;/code&gt;：构建 LLM 调用上下文（含历史轮次压缩、active research context 等）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;normalization.py&lt;/code&gt; — &lt;code&gt;normalize_contract_targets()&lt;/code&gt;：规范化 targets（别名、缩写、大小写统一）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contextual_resolver.py&lt;/code&gt; — 根据会话上下文消解实体引用（&quot;它的实验结果&quot; → 指上一轮那篇论文）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contextual_helpers.py&lt;/code&gt; — 上下文辅助函数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conversation_memory.py&lt;/code&gt; — 跨轮次 memory bindings（&lt;code&gt;active_memory_bindings()&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conversation_helpers.py&lt;/code&gt; — 对话状态辅助&lt;/li&gt;
&lt;li&gt;&lt;code&gt;followup_relationship.py&lt;/code&gt; — 追问关系继承和纠正检测&lt;/li&gt;
&lt;li&gt;&lt;code&gt;context.py&lt;/code&gt; — &lt;code&gt;contract_has_note()&lt;/code&gt;、&lt;code&gt;contract_notes()&lt;/code&gt; 等辅助&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;7.11 Claim 求解与验证（claims/）&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;claims/&lt;/code&gt; 是最大的领域子包（23 模块），负责三类核心工作：&lt;strong&gt;求解&lt;/strong&gt;（从 evidence 生成 claim）、&lt;strong&gt;验证&lt;/strong&gt;（grounding 校验）、&lt;strong&gt;辅助&lt;/strong&gt;（文本/公式/图表处理）。&lt;/p&gt;
&lt;p&gt;求解器入口 &lt;code&gt;solver_pipeline.py&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def run_claim_solver_pipeline(*, schema_allowed, generic_enabled, shadow_enabled,
                               solve_schema, solve_deterministic) -&amp;gt; ClaimSolverPipelineResult:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三路径策略：schema solver 可用且产出非空 → 直接用；否则走 deterministic solver；shadow mode 启用时两者并行运行并比较。&lt;/p&gt;
&lt;p&gt;13 个 deterministic solver（&lt;code&gt;_DETERMINISTIC_SOLVER_REGISTRY&lt;/code&gt;）：
&lt;code&gt;origin_lookup&lt;/code&gt;, &lt;code&gt;formula&lt;/code&gt;（公式提取+变量解释）, &lt;code&gt;followup_research&lt;/code&gt;, &lt;code&gt;figure&lt;/code&gt;（VLM 图表理解）, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;metric_context&lt;/code&gt;, &lt;code&gt;paper_recommendation&lt;/code&gt;, &lt;code&gt;topology_recommendation&lt;/code&gt;, &lt;code&gt;topology_discovery&lt;/code&gt;, &lt;code&gt;paper_summary_results&lt;/code&gt;, &lt;code&gt;default_text&lt;/code&gt;, &lt;code&gt;entity_definition&lt;/code&gt;, &lt;code&gt;concept_definition&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;验证器：&lt;code&gt;verifier_pipeline.py&lt;/code&gt; 编排验证流程；&lt;code&gt;type_verifiers.py&lt;/code&gt; 按 claim 类型做确定性校验；&lt;code&gt;llm_verifier.py&lt;/code&gt; 处理复杂语义判断。&lt;code&gt;deterministic_runner.py&lt;/code&gt; 和 &lt;code&gt;deterministic_solver.py&lt;/code&gt; 提供 solver 执行基础设施。&lt;/p&gt;
&lt;p&gt;辅助模块：&lt;code&gt;formula_text.py&lt;/code&gt;（公式 token 提取与加权）、&lt;code&gt;metric_text.py&lt;/code&gt;（指标数值提取）、&lt;code&gt;visual_helpers.py&lt;/code&gt;（图像/图表信号）、&lt;code&gt;paper_helpers.py&lt;/code&gt; / &lt;code&gt;paper_summary.py&lt;/code&gt;（论文元信息）、&lt;code&gt;origin_selection.py&lt;/code&gt;（起源论文选择）、&lt;code&gt;followup_helpers.py&lt;/code&gt;（追问处理）、&lt;code&gt;generic_solver.py&lt;/code&gt;（通用 schema solver）。&lt;/p&gt;
&lt;h3&gt;7.12 答案组合与实体（answers/ + entities/ + followup/ + clarification/）&lt;/h3&gt;
&lt;p&gt;这四个子包负责将 claim 转化为最终回答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;answers/（10 模块）&lt;/strong&gt;：&lt;code&gt;AnswerComposerMixin&lt;/code&gt; 调用各 answer composer。&lt;code&gt;evidence_presentation.py&lt;/code&gt; 构建引用格式（&lt;code&gt;citations_from_doc_ids()&lt;/code&gt;、&lt;code&gt;build_figure_contexts()&lt;/code&gt;）。&lt;code&gt;citation_whitelist.py&lt;/code&gt; 提供回答引用白名单后置过滤（P0-1 安全加固）。&lt;code&gt;entity.py&lt;/code&gt; / &lt;code&gt;formula.py&lt;/code&gt; / &lt;code&gt;paper.py&lt;/code&gt; / &lt;code&gt;followup.py&lt;/code&gt; / &lt;code&gt;topology.py&lt;/code&gt; / &lt;code&gt;library_recommendations.py&lt;/code&gt; / &lt;code&gt;memory_followup.py&lt;/code&gt; 各处理一种 answer 类型。&lt;code&gt;conversation_state.py&lt;/code&gt; 管理对话回答状态。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;entities/（4 模块）&lt;/strong&gt;：实体定义相关。&lt;code&gt;definition_helpers.py&lt;/code&gt; 提供实体定义求解辅助；&lt;code&gt;definition_profiles.py&lt;/code&gt; 定义实体 marker profile；&lt;code&gt;supporting_paper_selector.py&lt;/code&gt; 选择支撑论文；&lt;code&gt;type_inference.py&lt;/code&gt; 推断实体类型。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;followup/（2 模块）&lt;/strong&gt;：&lt;code&gt;candidates.py&lt;/code&gt; 生成追问候选；&lt;code&gt;relationship_memory.py&lt;/code&gt; 管理追问关系记忆。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;clarification/（3 模块）&lt;/strong&gt;：&lt;code&gt;intents.py&lt;/code&gt; 处理澄清意图（&lt;code&gt;contract_from_selected_clarification_option()&lt;/code&gt;、&lt;code&gt;clarification_options_from_contract_notes()&lt;/code&gt;）；&lt;code&gt;questions.py&lt;/code&gt; 构建澄清问题（&lt;code&gt;build_agent_clarification_question()&lt;/code&gt;）；&lt;code&gt;limit_runtime.py&lt;/code&gt; 限制澄清次数（&lt;code&gt;force_best_effort_after_clarification_limit()&lt;/code&gt;）。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;7.13 Agent Mixin 架构（agent_mixins/）&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;agent_mixins/&lt;/code&gt;（6 模块）是 Agent 架构的核心创新——将正交能力通过 Mixin 模式注入 &lt;code&gt;ResearchAssistantAgent&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ResearchAssistantAgentV4(
    FollowupRoutingMixin,     # 追问路由：is_negative_correction_query, inherit_followup_relationship
    AnswerComposerMixin,      # 答案组合：compose_formula_answer, compose_paper_summary_results_answer 等
    EntityDefinitionMixin,    # 实体定义：消歧 + 定义提取
    SolverPipelineMixin,      # Claim 求解：_run_solvers → run_claim_solver_pipeline
    ClaimVerifierMixin,       # Grounding 校验：_verify_claims, _verify_claims_with_schema
):
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种设计让每个 Mixin 只关注自己的领域，不互相污染。&lt;code&gt;concept_reasoning.py&lt;/code&gt; 是预留的概念推理 Mixin（未注入类继承）。&lt;/p&gt;
&lt;h3&gt;7.14 动态工具系统（tools/）&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;tools/&lt;/code&gt;（3 模块）支持在不修改核心代码的情况下扩展 Agent 能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;proposals.py&lt;/code&gt; — 工具提案管理：&lt;code&gt;list_tool_proposals()&lt;/code&gt;、&lt;code&gt;load_tool_proposal()&lt;/code&gt;、&lt;code&gt;run_tool_proposal_sandbox()&lt;/code&gt;、&lt;code&gt;transition_tool_proposal_status()&lt;/code&gt;。生命周期：draft → sandboxed → active → deprecated&lt;/li&gt;
&lt;li&gt;&lt;code&gt;registry_helpers.py&lt;/code&gt; — 700+ 行的工具注册基础设施，为 &lt;code&gt;tool_registries.py&lt;/code&gt; 提供 handler 辅助函数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dynamic_context.py&lt;/code&gt; — 动态工具上下文管理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;工具提案的 JSON manifest 包含 name、when、returns、input_schema、dangerous、streaming 等字段，与内置 &lt;code&gt;AgentToolSpec&lt;/code&gt; 格式兼容，由 &lt;code&gt;agent_tool_manifest()&lt;/code&gt; 合并后统一呈现给 LLM planner。&lt;/p&gt;
&lt;h2&gt;8. Agent 主链路&lt;/h2&gt;
&lt;p&gt;Agent 主链路是整个系统最核心的运行时流程。从用户输入到最终答案，数据依次经过意图路由（Intent Router）、合约提取（Contract Extraction）、工具规划（Agent Planner）、工具执行（Agent Runtime / Tool Loop）、claim 求解（Solver Pipeline）、grounding 验证（Claim Verifier）和答案组合（Answer Composer）七个阶段。每个阶段都有明确的输入输出协议，阶段之间通过 &lt;code&gt;QueryContract&lt;/code&gt;、&lt;code&gt;ResearchPlan&lt;/code&gt;、&lt;code&gt;EvidenceBlock&lt;/code&gt;、&lt;code&gt;Claim&lt;/code&gt;、&lt;code&gt;VerificationReport&lt;/code&gt; 等 domain model 传递状态。&lt;/p&gt;
&lt;p&gt;整个链路的入口是 &lt;code&gt;run_agent_chat_turn()&lt;/code&gt;（&lt;code&gt;app/services/agent/chat_runtime.py&lt;/code&gt;），它创建 &lt;code&gt;AgentRunContext&lt;/code&gt;，先尝试 compound query（复合查询分解），再走 &lt;code&gt;run_standard_turn()&lt;/code&gt; 标准流程。标准流程又细分为：&lt;code&gt;extract_agent_query_contract()&lt;/code&gt; 提取合约 → planner 生成 plan → runtime 执行 tool loop → solver 生成 claim → verifier 校验 → composer 组合答案 → &lt;code&gt;finish_agent_turn()&lt;/code&gt; 写入 trace。&lt;/p&gt;
&lt;h3&gt;8.1 意图路由：LLMIntentRouter&lt;/h3&gt;
&lt;p&gt;意图路由是整个链路的入口，由 &lt;code&gt;LLMIntentRouter&lt;/code&gt;（&lt;code&gt;app/services/intents/router.py&lt;/code&gt;）负责。核心创新：用 &lt;strong&gt;tool-calling 模式&lt;/strong&gt;代替传统文本分类。Chat Model 从五个 tool choice 中选择最合适的一个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ROUTER_TOOLS: list[dict[str, Any]] = [
    {&quot;name&quot;: &quot;answer_directly&quot;,        # 寒暄、自我介绍——不需要论文语料
     &quot;description&quot;: &quot;Use when the answer does not need local PDF/web evidence.&quot;,
     &quot;input_schema&quot;: {&quot;properties&quot;: {&quot;rationale&quot;: ..., &quot;confidence&quot;: ..., &quot;answer_style&quot;: ...}}},
    {&quot;name&quot;: &quot;need_conversation_tool&quot;,  # 论文库状态、推荐、引用排名、记忆追问
     &quot;input_schema&quot;: {&quot;properties&quot;: {&quot;relation&quot;: ..., &quot;targets&quot;: ..., &quot;requested_fields&quot;: ...}}},
    {&quot;name&quot;: &quot;need_corpus_search&quot;,      # 需要本地 PDF 语料检索——多数研究问题的入口
     &quot;input_schema&quot;: {&quot;properties&quot;: {&quot;relation&quot;: ..., &quot;targets&quot;: ..., &quot;requested_fields&quot;: ...}}},
    {&quot;name&quot;: &quot;need_web&quot;,                # 外部 Web 搜索（需用户显式开启）
     &quot;input_schema&quot;: {...}},
    {&quot;name&quot;: &quot;need_clarify&quot;,            # 问题不明确或存在歧义
     &quot;input_schema&quot;: {...}},
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;路由时模型同时输出 &lt;code&gt;relation&lt;/code&gt;（20+ 种：&lt;code&gt;formula_lookup&lt;/code&gt;、&lt;code&gt;paper_summary_results&lt;/code&gt;、&lt;code&gt;entity_definition&lt;/code&gt;、&lt;code&gt;metric_value_lookup&lt;/code&gt; 等）、&lt;code&gt;targets&lt;/code&gt;、&lt;code&gt;requested_fields&lt;/code&gt;、&lt;code&gt;confidence&lt;/code&gt;、&lt;code&gt;continuation_mode&lt;/code&gt;（fresh/followup/context_switch）。路由结果通过 &lt;code&gt;query_contract_from_router_decision()&lt;/code&gt; 转化为初始 &lt;code&gt;QueryContract&lt;/code&gt;，再由 &lt;code&gt;extract_agent_query_contract()&lt;/code&gt; 做规范化——包括继承 followup 上下文、应用 conversation memory、处理 pending clarification、normalize targets 等后续加工。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LLMIntentRouter.route()&lt;/code&gt; 接收会话 context（active_research、历史 turns、working_memory、persistent_learnings），判断跟进/纠正/切换关系。confidence 低于 &lt;code&gt;confidence_floor&lt;/code&gt;（默认 0.6）时走 fallback。&lt;/p&gt;
&lt;h3&gt;8.2 AgentPlanner&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AgentPlanner&lt;/code&gt;（&lt;code&gt;app/services/agent/planner.py&lt;/code&gt;）负责把 &lt;code&gt;QueryContract&lt;/code&gt; 转化为可执行的工具计划：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AgentPlanner:
    def __init__(self, *, clients, conversation_context, conversation_messages,
                 is_negative_correction_query, confidence_floor=0.6,
                 dynamic_tool_manifest=None) -&amp;gt; None:
        ...

    def tool_manifest(self) -&amp;gt; list[dict[str, Any]]:
        &quot;&quot;&quot;合并内置 + 动态工具的完整清单，去重后返回给 LLM&quot;&quot;&quot;
        manifest = []
        seen = set()
        for tool in [*agent_tool_manifest(), *list(self.dynamic_tool_manifest() or [])]:
            name = str(tool.get(&quot;name&quot;) or &quot;&quot;).strip()
            if name and name not in seen:
                seen.add(name)
                manifest.append(dict(tool))
        return manifest

    def plan_actions(self, *, contract, session, use_web_search) -&amp;gt; dict[str, Any]:
        &quot;&quot;&quot;生成初始工具序列&quot;&quot;&quot;
        fallback = fallback_plan(contract=contract, ...)
        if self.clients.chat is None: return fallback
        # 先尝试 tool-calling planner
        tool_plan = self.plan_with_tool_calls(contract, session, use_web_search)
        normalized = normalize_plan_payload(tool_plan, fallback, self.tool_names())
        if normalized is not None:
            return defer_premature_research_clarification(contract, normalized, fallback)
        # 失败则走 JSON planner
        payload = self.clients.invoke_json_messages(
            system_prompt=json_planner_system_prompt(context_payload),
            messages=planner_messages_with_user(...),
            fallback=fallback,
        )
        return normalize_plan_payload(payload, fallback, self.tool_names()) or fallback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心方法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;plan_actions()&lt;/code&gt;：生成初始工具序列。先尝试 tool-calling planner，失败则降级到 JSON planner，最终兜底用 &lt;code&gt;fallback_plan()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;next_action()&lt;/code&gt;：tool loop 每步调用，根据已执行的 actions 和当前状态决定下一步&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tool_manifest()&lt;/code&gt;：合并 20 个内置 &lt;code&gt;AgentToolSpec&lt;/code&gt; + 动态 JSON manifest 工具&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;防呆逻辑：&lt;code&gt;defer_premature_research_clarification()&lt;/code&gt; 避免检索前过早澄清；&lt;code&gt;fallback_plan()&lt;/code&gt; 在模型规划失败时提供安全默认序列（conversation → &lt;code&gt;[&quot;compose&quot;]&lt;/code&gt;，research → &lt;code&gt;[&quot;search_corpus&quot;, &quot;compose&quot;]&lt;/code&gt;）。&lt;/p&gt;
&lt;h3&gt;8.3 AgentRuntime 与 Tool Loop&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AgentRuntime&lt;/code&gt;（&lt;code&gt;app/services/agent/runtime.py&lt;/code&gt;）是工具执行调度器。区分两条路径，但共享同一套 &lt;code&gt;execute_tool_loop()&lt;/code&gt; 机制。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conversation 路径&lt;/strong&gt; — &lt;code&gt;execute_conversation_tools()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def execute_conversation_tools(self, *, contract, query, session, agent_plan,
                                max_web_results, emit, execution_steps) -&amp;gt; dict[str, Any]:
    actions = conversation_runtime_actions(contract=contract, agent_plan=agent_plan, ...)
    state = conversation_runtime_state(contract=contract, agent_plan=agent_plan)
    record_tool_loop_ready(emit=emit, tool=&quot;compose&quot;, actions=actions, ...)

    tools = build_conversation_tool_registry(agent=self.agent, state=state, ...)
    executor = AgentToolExecutor(tools)
    execute_tool_loop(
        agent=self.agent, executor=executor,
        planned_actions=actions,
        stop_condition=lambda executed: bool(state.get(&quot;answer&quot;)),
        ...
    )
    if not state.get(&quot;answer&quot;):
        executor.run(&quot;compose&quot;)       # loop 未产出答案时兜底调用 compose
    return state
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Research 路径&lt;/strong&gt; — &lt;code&gt;run_research_agent_loop()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def run_research_agent_loop(self, *, contract, session, agent_plan, web_enabled, ...) -&amp;gt; dict[str, Any]:
    plan = build_research_plan(contract=contract, settings=self.agent.settings)
    state = research_runtime_state(contract=contract, plan=plan, ...)
    emit(&quot;plan&quot;, plan.model_dump())   # 发出 ResearchPlan 事件
    execution_steps.append({&quot;node&quot;: &quot;agent_tool:build_research_plan&quot;, &quot;summary&quot;: &quot;,&quot;.join(plan.solver_sequence)})

    actions = research_runtime_actions(contract=contract, agent_plan=agent_plan, web_enabled=web_enabled)
    record_tool_loop_ready(emit=emit, tool=tool_loop_ready_tool(actions), actions=actions, ...)

    tools = build_research_tool_registry(agent=self.agent, state=state, session=session, ...)
    executor = AgentToolExecutor(tools)
    execute_tool_loop(
        agent=self.agent, executor=executor,
        planned_actions=actions,
        stop_condition=lambda executed: (
            isinstance(state.get(&quot;verification&quot;), VerificationReport)
            and state[&quot;verification&quot;].status in {&quot;pass&quot;, &quot;clarify&quot;}
        ),
        ...
    )
    if state[&quot;verification&quot;] is None and not state.get(&quot;answer&quot;):
        executor.run(&quot;compose&quot;)       # verification 未生成时兜底
    finalize_research_runtime(agent=self.agent, state=state, emit=emit, ...)
    return state
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两条路径的 key difference：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Conversation 的 &lt;code&gt;stop_condition&lt;/code&gt;：&lt;code&gt;state[&quot;answer&quot;]&lt;/code&gt; 非空即停&lt;/li&gt;
&lt;li&gt;Research 的 &lt;code&gt;stop_condition&lt;/code&gt;：&lt;code&gt;verification.status&lt;/code&gt; 为 &lt;code&gt;pass&lt;/code&gt; 或 &lt;code&gt;clarify&lt;/code&gt; 才停&lt;/li&gt;
&lt;li&gt;Research 多了 &lt;code&gt;build_research_plan&lt;/code&gt; 步（内置非 LLM 可见工具），产出 &lt;code&gt;solver_sequence&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Conversation 注册了 &lt;code&gt;query_library_metadata&lt;/code&gt;（直接 SQL 查论文库），Research 没有&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Tool loop 运作方式：planner 决定 next action → executor 调用对应 handler → handler 通过 emit 产出事件 → 检查 stop_condition → 继续或终止。默认 &lt;code&gt;max_agent_steps=8&lt;/code&gt;、&lt;code&gt;retry_budget=1&lt;/code&gt; 防止无限循环。&lt;/p&gt;
&lt;h3&gt;8.4 Tool Registry 与 AgentToolExecutor&lt;/h3&gt;
&lt;p&gt;工具系统三层结构（&lt;code&gt;app/services/agent/tools.py&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass(frozen=True, slots=True)
class AgentToolSpec:
    &quot;&quot;&quot;LLM 可见的工具声明（20 个内置 + 动态）&quot;&quot;&quot;
    name: str
    when: str                      # 使用场景描述
    returns: str                   # 返回内容描述
    input_schema: dict[str, Any]   # 参数 JSON Schema
    research_executable: bool = False
    conversation_executable: bool = False
    dangerous: bool = False
    streaming: bool = False

@dataclass(frozen=True, slots=True)
class RegisteredAgentTool:
    &quot;&quot;&quot;Runtime 注册的可执行工具&quot;&quot;&quot;
    name: str
    handler: Callable              # 实际执行函数
    requires: tuple[str, ...] = () # 前置依赖工具
    terminal: bool = False         # 是否终止 loop
    accepts_arguments: bool = False
    streaming: bool = False

class AgentToolExecutor:
    &quot;&quot;&quot;工具执行器：注册 → 依赖解析 → 执行 → 去重&quot;&quot;&quot;
    def __init__(self, tools: dict[str, RegisteredAgentTool]): ...
    def run(self, action, *, arguments=None, argument_provider=None, emit=None) -&amp;gt; bool:
        &quot;&quot;&quot;执行工具。先解析 requires 依赖，再调用 handler，返回是否 terminal&quot;&quot;&quot;
    def run_parallel(self, actions, *, arguments=None, max_workers=4, emit=None) -&amp;gt; bool:
        &quot;&quot;&quot;并行执行多个独立工具&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行时 &lt;code&gt;AgentToolExecutor.run()&lt;/code&gt; 自动：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;解析 &lt;code&gt;requires&lt;/code&gt; 依赖链（依赖未执行则先执行依赖）&lt;/li&gt;
&lt;li&gt;按 &lt;code&gt;(tool.name, arguments_fingerprint)&lt;/code&gt; 去重&lt;/li&gt;
&lt;li&gt;emit &lt;code&gt;thinking_delta&lt;/code&gt; 事件（&quot;调用 search_corpus(query=..., scope=auto)...&quot;）&lt;/li&gt;
&lt;li&gt;通过 &lt;code&gt;begin_tool_execution()&lt;/code&gt; / &lt;code&gt;end_tool_execution()&lt;/code&gt; 记录耗时和 metrics&lt;/li&gt;
&lt;li&gt;terminal 工具（&lt;code&gt;compose&lt;/code&gt;、&lt;code&gt;ask_human&lt;/code&gt;）返回 True 触发 loop 终止检查&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;工具注册表分别由 &lt;code&gt;build_conversation_tool_registry()&lt;/code&gt; (12 工具) 和 &lt;code&gt;build_research_tool_registry()&lt;/code&gt; (18 工具) 在 &lt;code&gt;tool_registries.py&lt;/code&gt; 中构建，两者都通过 &lt;code&gt;_add_dynamic_tools()&lt;/code&gt; 注入动态工具。&lt;/p&gt;
&lt;h3&gt;8.5 Search Corpus 检索执行&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;search_corpus&lt;/code&gt; 是 research 路径最核心的工具，在 tool loop 中被多次调用。&lt;code&gt;research_search_handlers.py&lt;/code&gt; 中的 &lt;code&gt;search_corpus()&lt;/code&gt; handler：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def search_corpus(arguments=None) -&amp;gt; None:
    request_input = planned_input(&quot;search_corpus&quot;, arguments)
    strategy = search_corpus_strategy(request_input)  # 解析 strategy 参数
    # 如果指定了原子检索策略，直接委托
    if strategy in {&quot;bm25&quot;, &quot;vector&quot;, &quot;hybrid&quot;}:
        run_atomic_search(f&quot;{strategy}_search&quot;, request_input)
        return
    # 否则走两阶段：先找候选论文，再找证据块
    if not state.get(&quot;screened_papers&quot;):
        search_papers(request_input)       # → agent_search_papers() → DualIndexRetriever.search_papers()
    if not state.get(&quot;evidence&quot;):
        search_evidence(request_input)     # → agent_search_evidence() → DualIndexRetriever.search_blocks()
    summary, payload = search_corpus_observation_payload(state)
    record_observation(tool=&quot;search_corpus&quot;, summary=summary, payload=payload)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;agent_search_papers()&lt;/code&gt; 调用 &lt;code&gt;DualIndexRetriever.search_papers()&lt;/code&gt; → 产出 candidate_papers → &lt;code&gt;screen_agent_papers()&lt;/code&gt; 筛选 → &lt;code&gt;screened_papers&lt;/code&gt; 事件。&lt;code&gt;agent_search_evidence()&lt;/code&gt; 调用 &lt;code&gt;search_blocks()&lt;/code&gt; → &lt;code&gt;evidence&lt;/code&gt; 事件。&lt;/p&gt;
&lt;p&gt;消歧发生在后续的 &lt;code&gt;agent_solve_claims()&lt;/code&gt;（compose handler 中）：先通过 &lt;code&gt;disambiguation_options_from_evidence()&lt;/code&gt; 生成歧义选项，再由 &lt;code&gt;judge_disambiguation_options()&lt;/code&gt; 调用 LLM-judge，高置信度（≥0.85）时自动绑定，低置信度时标记 &lt;code&gt;judge_recommended&lt;/code&gt; 或进入人工澄清。&lt;/p&gt;
&lt;h3&gt;8.6 Claim Solver：SolverPipelineMixin&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SolverPipelineMixin._run_solvers()&lt;/code&gt; 将 evidence 转化为结构化 &lt;code&gt;Claim&lt;/code&gt;。核心执行流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class SolverPipelineMixin:
    def _run_solvers(self, *, contract, plan, papers, evidence, session) -&amp;gt; list[Claim]:
        schema_allowed = should_use_schema_claim_solver(contract=contract, plan=plan, ...)
        result = run_claim_solver_pipeline(
            schema_allowed=schema_allowed,
            generic_enabled=...,       # 是否启用通用 schema solver
            shadow_enabled=...,        # 是否启用 shadow mode（双跑对比）
            solve_schema=lambda: self._run_schema_claim_solver(...),
            solve_deterministic=lambda: self._run_deterministic_claim_solver(...),
        )
        return result.claims
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_DETERMINISTIC_SOLVER_REGISTRY&lt;/code&gt; 注册了 13 种 solver：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_DETERMINISTIC_SOLVER_REGISTRY = {
    &quot;origin_lookup&quot;:          solve_origin_lookup_claims,       # 概念起源论文
    &quot;formula&quot;:                solve_formula_claims,             # 公式 + 变量提取
    &quot;followup_research&quot;:      solve_followup_research_claims,   # 多轮追问
    &quot;figure&quot;:                 solve_figure_claims,              # 图表理解 (VLM)
    &quot;table&quot;:                  solve_table_claims,               # 表格解析
    &quot;metric_context&quot;:         solve_metric_context_claims,      # 指标数值提取
    &quot;paper_recommendation&quot;:   solve_paper_recommendation_claims,
    &quot;topology_recommendation&quot;: solve_topology_recommendation_claims,
    &quot;topology_discovery&quot;:     solve_topology_discovery_claims,
    &quot;paper_summary_results&quot;:  solve_paper_summary_results_claims,
    &quot;default_text&quot;:           solve_default_text_claims,
    &quot;entity_definition&quot;:      solve_entity_definition_claims,
    &quot;concept_definition&quot;:     solve_concept_definition_claims,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;run_claim_solver_pipeline()&lt;/code&gt; 执行策略：schema solver 可用且产出非空 → 直接用 schema 结果；否则走 deterministic solver。shadow mode 启用时两者并行运行，比较结果写入 &lt;code&gt;_last_generic_claim_solver_shadow&lt;/code&gt; 供 runtime_summary 展示。&lt;/p&gt;
&lt;p&gt;每个 solver 接收 &lt;code&gt;(contract, plan, papers, evidence, ...)&lt;/code&gt; → 返回 &lt;code&gt;list[Claim]&lt;/code&gt;。例如 DPO 公式查询中，&lt;code&gt;formula_solver&lt;/code&gt; 从 evidence 提取 &lt;code&gt;L_DPO&lt;/code&gt; 公式、β 参数、π_θ 策略定义等结构化 claim。&lt;/p&gt;
&lt;h3&gt;8.7 Claim Verifier：ClaimVerifierMixin&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ClaimVerifierMixin._verify_claims()&lt;/code&gt; 实现三层验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _verify_claims(self, *, contract, plan, claims, papers, evidence) -&amp;gt; VerificationReport:
    if not claims:
        return VerificationReport(status=&quot;retry&quot;, missing_fields=plan.required_claims,
                                  recommended_action=&quot;expand_recall&quot;)
    # ── 第一层：Deterministic 证据 ID 审计 ──
    # 检查 claim 引用的 evidence_ids 是否真实存在于当前 evidence 列表中
    # 引用了不存在 doc_id 的 claim → unsupported（可能是 LLM 幻觉）
    real_doc_ids = {e.doc_id for e in evidence} | {p.paper_id for p in papers}
    orphan_claims = [c for c in claims if c.evidence_ids and not (c.evidence_ids &amp;amp; real_doc_ids)]
    if orphan_claims:
        return VerificationReport(status=&quot;clarify&quot;, unsupported_claims=orphan_claims, ...)

    # ── 第二层：Schema / Type-specific 验证 ──
    schema_report = self._verify_claims_with_schema(contract, plan, claims, papers, evidence)
    if schema_report is not None:
        return schema_report

    # ── 第三层：Generic fallback 验证 ──
    report = self._verify_claims_with_generic_fallback(contract, plan, claims, papers, evidence)
    return report or VerificationReport(status=&quot;pass&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第二层 type-specific 验证器（&lt;code&gt;app/services/claims/type_verifiers.py&lt;/code&gt;）按 relation 分发：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;verify_formula_lookup_claims()&lt;/code&gt;：检查公式是否含数学表达式 + 变量解释 + 匹配目标&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verify_metric_value_lookup_claims()&lt;/code&gt;：精确数值比对（允许舍入误差，不允许语义近似）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verify_origin_lookup_claims()&lt;/code&gt;：检查起源引用的论文是否确实提出了该概念&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verify_figure_question_claims()&lt;/code&gt;：检查图表 claim 的图像证据支持&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verify_followup_research_claims()&lt;/code&gt;：检查追问与上轮上下文一致性&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第三层 LLM 验证器（&lt;code&gt;app/services/claims/llm_verifier.py&lt;/code&gt;）用于复杂语义判断，如 &lt;code&gt;verify_formula_claims_with_llm()&lt;/code&gt; 对比 claim 和原始公式的一致性。&lt;/p&gt;
&lt;p&gt;最终 &lt;code&gt;VerificationReport&lt;/code&gt; 含 &lt;code&gt;status&lt;/code&gt;（pass/retry/clarify）、&lt;code&gt;missing_fields&lt;/code&gt;、&lt;code&gt;unsupported_claims&lt;/code&gt;、&lt;code&gt;contradictory_claims&lt;/code&gt;、&lt;code&gt;recommended_action&lt;/code&gt;。非 pass 时触发 retry 或澄清。&lt;/p&gt;
&lt;h3&gt;8.8 Answer Composer：AnswerComposerMixin&lt;/h3&gt;
&lt;p&gt;Answer Composer 把验证通过的 claim 转化为 Markdown 回答。核心分发机制在 &lt;code&gt;tool_registries.py&lt;/code&gt; 的 &lt;code&gt;compose()&lt;/code&gt; handler 中——conversation 路径通过 &lt;code&gt;_COMPOSE_RELATION_STEPS&lt;/code&gt; 表按 relation 分发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_COMPOSE_RELATION_STEPS: dict[str, list[Callable[[], None]]] = {
    &quot;library_status&quot;:           [query_library_metadata, get_library_status],
    &quot;library_recommendation&quot;:   [get_library_recommendation],
    &quot;memory_followup&quot;:          [answer_from_memory],
    &quot;memory_synthesis&quot;:         [synthesize_previous_results],
    &quot;library_citation_ranking&quot;: [recover_previous_recommendation_candidates,
                                 web_citation_lookup,
                                 rank_by_verified_citation_count],
}

def compose() -&amp;gt; None:
    steps = _COMPOSE_RELATION_STEPS.get(contract.relation)
    if steps is not None:
        for step_fn in steps:
            step_fn()                        # 按顺序执行该 relation 对应的步骤
    elif not state.get(&quot;answer&quot;):
        answer_conversation()                # fallback: 通用对话回答
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Research 路径的 compose 在 &lt;code&gt;research_compose_handlers.py&lt;/code&gt; 中——&lt;code&gt;agent_solve_claims()&lt;/code&gt; 调用 &lt;code&gt;SolverPipelineMixin._run_solvers()&lt;/code&gt; → &lt;code&gt;ClaimVerifierMixin._verify_claims()&lt;/code&gt; → 消歧 → 生成最终答案。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AnswerComposerMixin&lt;/code&gt; 提供的具体 composer 方法按 &lt;code&gt;relation&lt;/code&gt; 分发：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;公式回答&lt;/strong&gt;（&lt;code&gt;compose_formula_answer()&lt;/code&gt;）：格式化公式、变量解释表、来源引用&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;论文摘要回答&lt;/strong&gt;（&lt;code&gt;compose_paper_summary_results_answer()&lt;/code&gt;）：目标、方法、结果&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;指标回答&lt;/strong&gt;（&lt;code&gt;compose_metric_value_answer()&lt;/code&gt;）：精确数值、单位、上下文&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;追问回答&lt;/strong&gt;（&lt;code&gt;compose_followup_research_answer()&lt;/code&gt;）：结合上轮上下文&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;论文推荐回答&lt;/strong&gt;：推荐列表、理由、论文卡片&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;论文库状态回答&lt;/strong&gt;：论文数、分类统计&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记忆追问回答&lt;/strong&gt;（&lt;code&gt;compose_memory_followup_answer()&lt;/code&gt;）：从 working memory 检索&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所有 composer 统一输出 &lt;code&gt;AssistantCitation&lt;/code&gt; 结构（doc_id、paper_id、title、authors、year、page、block_type、caption、snippet），回答中用 Markdown 引用标记，前端点击跳转 PDF 预览。&lt;/p&gt;
&lt;h3&gt;8.9 多轮记忆与澄清机制&lt;/h3&gt;
&lt;p&gt;合约提取是连接意图路由和后续执行的关键环节。&lt;code&gt;extract_agent_query_contract()&lt;/code&gt;（&lt;code&gt;app/services/agent/contract_extraction.py&lt;/code&gt;）实现多层加工：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def extract_agent_query_contract(*, agent, query, session, mode,
                                  clarification_choice=None) -&amp;gt; QueryContract:
    clean_query = &quot; &quot;.join(query.strip().split())
    # 1. 检查是否有 pending clarification（上轮追问等待用户选择）
    clarified = contract_from_pending_clarification(clean_query, session, clarification_choice)
    if clarified is not None: return clarified

    # 2. LLMIntentRouter 路由
    targets = extract_targets(clean_query)
    decision = agent.llm_intent_router.route(query=clean_query, session=session)
    contract = query_contract_from_router_decision(decision, clean_query, session, targets, ...)

    # 3. 规范化 + followup 上下文继承
    if contract.continuation_mode == &quot;followup&quot;:
        contract = inherit_followup_relationship_contract(contract, session)
    if contract.continuation_mode == &quot;context_switch&quot;:
        contract = normalize_followup_direction_contract(contract, session)
    contract = resolve_contextual_research_contract(contract, session)
    contract = normalize_contract_targets(contract, ...)
    return contract
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上下文压缩策略：&lt;code&gt;agent_session_conversation_context()&lt;/code&gt; 构建每次 LLM 调用的上下文——最近 4 轮 answer 保留 900 字符，更早轮次 280 字符，超过 8 轮压缩进 &lt;code&gt;summary_of_compressed_older_turns&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;FollowupRoutingMixin&lt;/code&gt; 处理追问路由——&lt;code&gt;is_negative_correction_query()&lt;/code&gt; 检测否定纠正（&quot;不对，我要的是...&quot;），触发上下文清除和重新检索；&lt;code&gt;inherit_followup_relationship_contract()&lt;/code&gt; 为延续类问题继承 active_research。&lt;/p&gt;
&lt;p&gt;澄清机制：LLM-judge 消歧在 &lt;code&gt;disambiguation_runtime.py&lt;/code&gt; 中——&lt;code&gt;judge_disambiguation_options()&lt;/code&gt; 调用 LLM 判断自动绑定或人工澄清。&lt;code&gt;DISAMBIGUATION_AUTO_RESOLVE_THRESHOLD = 0.85&lt;/code&gt;（自动绑定），&lt;code&gt;DISAMBIGUATION_RECOMMEND_THRESHOLD = 0.65&lt;/code&gt;（推荐但不自动）。&lt;code&gt;force_best_effort_after_clarification_limit()&lt;/code&gt; 防止无限澄清循环。&lt;/p&gt;
&lt;h2&gt;9. 前端交互&lt;/h2&gt;
&lt;p&gt;前端由 &lt;code&gt;app/static/index.html&lt;/code&gt; 提供，是一个单页式论文研究工作台。页面布局分为四个主要区域：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;左侧 Zotero 论文库侧栏&lt;/strong&gt;：启动时通过 &lt;code&gt;GET /api/v1/library&lt;/code&gt; 加载论文列表，按 Zotero collection 分类展示，每篇论文显示标题、作者、年份和标签。点击论文可以触发右侧的 PDF 预览和论文信息面板。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;中间聊天区&lt;/strong&gt;：用户输入问题后，前端通过 &lt;code&gt;POST /api/v1/chat/stream&lt;/code&gt; 建立 SSE 长连接，实时接收后端事件。聊天区支持 Markdown 渲染（含 LaTeX 数学公式）、引用标记点击、流式文本追加和思考过程展示（&lt;code&gt;thinking_delta&lt;/code&gt; 事件）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;右侧 Runtime Inspector&lt;/strong&gt;：展示 Agent 执行过程中的关键事件。包括 session 信息、query contract（结构化意图）、agent plan（工具计划）、plan（研究计划细节）、observation（LLM 中间观察，如 disambiguation judge 决策）、agent_step（工具步骤开始）、thinking_delta（模型思考过程流式输出）、tool_call（工具调用和摘要）、candidate_papers / screened_papers（候选论文和筛选结果）、evidence（检索到的证据块数量）、solver_selection（solver 选择结果）、claims（生成的结论数量）、verification（grounding 校验状态）、confidence（置信度评估）和 reflection（Agent 自我反思）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;引用来源和 PDF 预览&lt;/strong&gt;：点击回答中的引用标记或 Runtime 面板中的 evidence 条目，前端会调用 &lt;code&gt;GET /api/v1/citations/preview?doc_id=...&amp;amp;paper_id=...&lt;/code&gt; 获取证据详情，并在 PDF 预览区展示对应论文的 PDF 页面（通过 &lt;code&gt;GET /api/v1/library/papers/{paper_id}/pdf&lt;/code&gt; 加载 PDF，使用 PDF.js 渲染到指定页码）。&lt;/p&gt;
&lt;p&gt;前端代码没有使用 React/Vue 等框架，而是基于原生 JavaScript + DOM 操作实现，配合 CSS Grid 布局。SSE 事件解析使用 fetch + ReadableStream reader，每个事件的 event 类型和 data payload 被解析后路由到对应的 UI 更新函数。Runtime 面板按时间线展示事件卡片，每张卡片显示事件类型、工具名、摘要和 payload 详情。前端的价值不在于前端工程复杂度，而在于把后端 Agent 的完整执行过程可视化，让用户不仅看到最终答案，也能看到 Agent 如何理解问题、调用工具、检索证据、消歧候选和完成校验。&lt;/p&gt;
&lt;h2&gt;10. 核心功能&lt;/h2&gt;
&lt;h3&gt;10.1 Zotero 论文库浏览&lt;/h3&gt;
&lt;p&gt;系统启动后从 Zotero SQLite 数据库读取所有论文记录，按 collection 分类展示在前端侧栏。支持按标题搜索、按分类筛选、查看论文详情（metadata、abstract、tags）、预览 PDF 首页。论文库数据通过 &lt;code&gt;LibraryBrowserService&lt;/code&gt; 统一提供，前端侧栏和论文预览使用同一套数据接口。&lt;/p&gt;
&lt;h3&gt;10.2 多模态 PDF 检索问答&lt;/h3&gt;
&lt;p&gt;支持多种问题类型和证据模态的组合检索。每种类型对应 &lt;code&gt;QueryContract.relation&lt;/code&gt;，驱动不同的 solver 和验证器：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;relation&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;solver&lt;/th&gt;
&lt;th&gt;证据模态&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;formula_lookup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;公式查询&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_formula_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text, table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metric_value_lookup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指标查询&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_metric_context_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text, table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;figure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;图表理解&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_figure_claims&lt;/code&gt; (VLM)&lt;/td&gt;
&lt;td&gt;figure, caption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;表格解析&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_table_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;entity_definition&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;实体定义&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_entity_definition_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;concept_definition&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;概念定义&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_concept_definition_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paper_summary_results&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;论文摘要&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_paper_summary_results_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;origin_lookup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;起源查找&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_origin_lookup_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;default_text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;通用问答&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_default_text_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paper_recommendation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;论文推荐&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_paper_recommendation_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;paper cards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;topology_discovery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;拓扑发现&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_topology_discovery_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;topology_recommendation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;拓扑推荐&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solve_topology_recommendation_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page_text&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;DPO 公式查询的实际 QueryContract（来自真实 trace）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;clean_query&quot;: &quot;帮我看看 DPO 这篇论文的核心公式&quot;,
  &quot;interaction_mode&quot;: &quot;research&quot;,
  &quot;relation&quot;: &quot;formula_lookup&quot;,
  &quot;targets&quot;: [&quot;DPO&quot;, &quot;Direct Preference Optimization&quot;],
  &quot;answer_slots&quot;: [&quot;formula&quot;],
  &quot;requested_fields&quot;: [&quot;formula&quot;, &quot;variable_explanation&quot;, &quot;source&quot;],
  &quot;required_modalities&quot;: [&quot;page_text&quot;, &quot;table&quot;],
  &quot;answer_shape&quot;: &quot;bullets&quot;,
  &quot;precision_requirement&quot;: &quot;exact&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;precision_requirement=exact&lt;/code&gt; 要求精确定位原文公式而非概念总结；&lt;code&gt;required_modalities&lt;/code&gt; 指导检索层按 block_type 过滤证据块。&lt;/p&gt;
&lt;h3&gt;10.3 多轮上下文记忆&lt;/h3&gt;
&lt;p&gt;系统通过 &lt;code&gt;SessionContext&lt;/code&gt; 维护多轮对话状态，包括当前研究主题（active_research）、历史轮次（turns）、工作记忆（working_memory）和持续学习（persistent_learnings）。追问时自动识别与上一轮的关系（延续/纠正/切换），复用或更新研究上下文。&lt;/p&gt;
&lt;h3&gt;10.4 引用溯源与 PDF 预览&lt;/h3&gt;
&lt;p&gt;回答中的每个结论都附带引用标记，指向具体的证据块（doc_id）和论文（paper_id）。用户点击引用可以查看证据片段、页码、块类型和原始 PDF 页面。引用的证据块类型涵盖 page_text、table、figure、formula_hint 等。&lt;/p&gt;
&lt;h3&gt;10.5 流式回答与运行时可视化&lt;/h3&gt;
&lt;p&gt;SSE 流式接口实时推送 Agent 执行全过程：Intent → Contract → Plan → Tool Loop → Evidence → Claims → Verification → Answer Delta。前端 Runtime 面板按时间线展示这些事件，用户可以追踪每一步的输入输出，调试和验证 Agent 的推理过程。&lt;/p&gt;
&lt;h3&gt;10.6 Web Search 补充检索&lt;/h3&gt;
&lt;p&gt;当本地论文库无法覆盖用户问题时（需用户显式开启），Agent 可以通过 Tavily API 进行 Web 搜索，获取外部网页信息，并转化为与本地证据格式兼容的 claim 和 citation。Web 来源在回答中会标记 &lt;code&gt;source_type=web&lt;/code&gt;，与本地 PDF 引用区分。&lt;/p&gt;
&lt;h3&gt;10.7 动态工具扩展&lt;/h3&gt;
&lt;p&gt;系统支持在不修改代码的情况下通过 JSON manifest 注册自定义工具。&lt;code&gt;load_agent_dynamic_tool_manifests()&lt;/code&gt; 从配置目录加载工具描述（name、when、returns、input_schema、dangerous、streaming），Agent Planner 和 Runtime 会自动将这些工具纳入工具清单和执行计划。API 层的 &lt;code&gt;ToolProposalSandboxRequest&lt;/code&gt; 和 &lt;code&gt;ToolProposalTransitionRequest&lt;/code&gt; 支持工具提案的生命周期管理。&lt;/p&gt;
&lt;h3&gt;10.8 Trace 持久化与调试&lt;/h3&gt;
&lt;p&gt;每次 Agent 运行结束后，完整的执行 trace（包括所有事件、execution_steps 和 final_payload）会写入 &lt;code&gt;data/traces/&amp;lt;session_id&amp;gt;/&lt;/code&gt; 目录下的 JSONL 文件。&lt;code&gt;scripts/diff_agent_traces.py&lt;/code&gt; 可以对两次运行的 trace 做 diff 对比，帮助定位 Agent 行为变化的原因。&lt;/p&gt;
&lt;h2&gt;11. 测试与评估&lt;/h2&gt;
&lt;h3&gt;11.1 单元测试&lt;/h3&gt;
&lt;p&gt;测试目录 &lt;code&gt;tests/&lt;/code&gt; 包含 80+ 个测试文件，覆盖了几乎所有服务模块。测试使用 pytest 框架，部分测试直接实例化模块进行单元测试，部分使用 &lt;code&gt;StubModelClients&lt;/code&gt; 替代真实模型调用以加速执行。&lt;/p&gt;
&lt;p&gt;关键测试覆盖范围：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Agent 核心流程：&lt;code&gt;test_agent_v4.py&lt;/code&gt; 测试完整的 Agent turn、contract extraction、planner、runtime、loop 和事件流&lt;/li&gt;
&lt;li&gt;意图路由：&lt;code&gt;test_intent_router.py&lt;/code&gt;、&lt;code&gt;test_conversation_intents.py&lt;/code&gt;、&lt;code&gt;test_research_intents.py&lt;/code&gt;、&lt;code&gt;test_figure_intents.py&lt;/code&gt;、&lt;code&gt;test_followup_intents.py&lt;/code&gt; 等测试不同场景下的意图识别&lt;/li&gt;
&lt;li&gt;Claim solvers：&lt;code&gt;test_formula_claim_solver.py&lt;/code&gt;、&lt;code&gt;test_generic_claim_solver.py&lt;/code&gt;、&lt;code&gt;test_concept_definition_solver.py&lt;/code&gt;、&lt;code&gt;test_entity_definition_claim_solver.py&lt;/code&gt;、&lt;code&gt;test_deterministic_claim_solver.py&lt;/code&gt; 等测试各类 solver&lt;/li&gt;
&lt;li&gt;Claim verifiers：&lt;code&gt;test_claim_type_verifiers.py&lt;/code&gt;、&lt;code&gt;test_claim_verifier_pipeline.py&lt;/code&gt;、&lt;code&gt;test_claim_verification_helpers.py&lt;/code&gt;、&lt;code&gt;test_llm_claim_verifier.py&lt;/code&gt; 测试验证逻辑&lt;/li&gt;
&lt;li&gt;检索与入库：&lt;code&gt;test_indexing_and_retrieval_hardening.py&lt;/code&gt;、&lt;code&gt;test_evidence_tools.py&lt;/code&gt;、&lt;code&gt;test_citation_ranking.py&lt;/code&gt;、&lt;code&gt;test_web_evidence.py&lt;/code&gt;、&lt;code&gt;test_url_fetcher.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;会话与记忆：&lt;code&gt;test_session_store.py&lt;/code&gt;、&lt;code&gt;test_session_context_helpers.py&lt;/code&gt;、&lt;code&gt;test_memory_artifact_helpers.py&lt;/code&gt;、&lt;code&gt;test_learnings.py&lt;/code&gt;、&lt;code&gt;test_research_memory.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;合约与意图适配：&lt;code&gt;test_intent_contract_adapter.py&lt;/code&gt;、&lt;code&gt;test_intent_marker_profiles.py&lt;/code&gt;、&lt;code&gt;test_contract_normalization.py&lt;/code&gt;、&lt;code&gt;test_contract_context.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;安全：&lt;code&gt;test_security.py&lt;/code&gt;、&lt;code&gt;test_prompt_safety.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;前端：&lt;code&gt;test_frontend_v5.py&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;11.2 Eval Cases&lt;/h3&gt;
&lt;p&gt;Eval cases 定义在 &lt;code&gt;evals/cases_test_md.yaml&lt;/code&gt; 中，使用 YAML 格式。&lt;code&gt;scripts/run_v4_eval.py&lt;/code&gt; 通过 HTTP 调用 &lt;code&gt;/api/v1/chat&lt;/code&gt; 接口自动判断通过/失败：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Eval case 结构示例
cases = [
    {
        &quot;query&quot;: &quot;帮我看看 DPO 这篇论文的核心公式&quot;,
        &quot;expect&quot;: {
            &quot;interaction_mode&quot;: &quot;research&quot;,
            &quot;has_citations&quot;: True,
            &quot;answer_markers&quot;: [&quot;DPO&quot;, &quot;公式&quot;, &quot;π&quot;],      # 期望出现的文本
            &quot;forbidden_markers&quot;: [&quot;证据不足&quot;, &quot;无法回答&quot;],  # 不应出现的文本
        }
    },
]

# 评判逻辑
def _evaluate_turn(response, expect):
    answer = response.get(&quot;answer&quot;, &quot;&quot;)
    if _contains_any(answer, expect.get(&quot;forbidden_markers&quot;, [])):
        return False, [&quot;forbidden_marker_found&quot;]
    if not _count_group_matches(answer, expect.get(&quot;answer_markers&quot;, [])):
        return False, [&quot;answer_markers_missing&quot;]
    return True, []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;评估指标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;insufficient_answer_rate&lt;/code&gt;：答案中出现&quot;证据不足&quot;标记的比例&lt;/li&gt;
&lt;li&gt;&lt;code&gt;marker_match_rate&lt;/code&gt;：期望标记命中率&lt;/li&gt;
&lt;li&gt;&lt;code&gt;citation_present_rate&lt;/code&gt;：有引用的回答比例&lt;/li&gt;
&lt;li&gt;&lt;code&gt;interaction_mode_accuracy&lt;/code&gt;：interaction_mode 分类准确率&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;11.3 关键回归场景&lt;/h3&gt;
&lt;p&gt;几个经常被回归测试覆盖的关键场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DPO 公式查询：验证缩写消歧 + 公式提取 + 变量解释的完整链路&lt;/li&gt;
&lt;li&gt;论文摘要查询：验证论文级检索 + 多证据融合 + 结构化摘要&lt;/li&gt;
&lt;li&gt;多轮追问：验证上下文继承、active_research 更新、纠正场景&lt;/li&gt;
&lt;li&gt;澄清场景：验证 LLM-judge 自动消歧和人工澄清的阈值边界&lt;/li&gt;
&lt;li&gt;论文库状态查询：验证 conversation 路径的 library_status 和 recommendation&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;11.4 检索对比评测&lt;/h3&gt;
&lt;p&gt;除了单元测试和 Eval Cases，系统还包含一套独立的检索模块对比评测框架，用于量化多路融合检索策略的实际效果。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;评测目标&lt;/strong&gt;：对比 Pure Dense（单路 Milvus 向量检索）、BM25+Dense RRF（双路等权融合）和 Enhanced（四路 Weighted RRF + LLM 目标提取）三个配置在论文级检索上的表现。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;评测集设计&lt;/strong&gt;：评测集 &lt;code&gt;data/eval_queries_v3.json&lt;/code&gt; 包含 159 道查询，覆盖 110/113 篇论文（覆盖率 97%）。构造方法遵循 IR 评测集标准流程——论文分类 → 分类型生成查询 → 难度分级 → Ground Truth 标注。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;论文分类&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;数量&lt;/th&gt;
&lt;th&gt;特征&lt;/th&gt;
&lt;th&gt;查询生成方式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;命名方法型&lt;/td&gt;
&lt;td&gt;43 篇&lt;/td&gt;
&lt;td&gt;标题以方法名开头+冒号（如 &lt;code&gt;LoRA: Low-Rank Adaptation...&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;模板自动生成：&quot;{method}是什么？&quot;、&quot;{method}的核心原理？&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;描述标题型&lt;/td&gt;
&lt;td&gt;70 篇&lt;/td&gt;
&lt;td&gt;完整句子描述贡献（如 &lt;code&gt;Learning Transferable Visual Models From Natural Language Supervision&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;手动构造，用领域知识将论文贡献映射为自然语言查询&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;难度分级&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;难度&lt;/th&gt;
&lt;th&gt;数量&lt;/th&gt;
&lt;th&gt;定义&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Easy&lt;/td&gt;
&lt;td&gt;18 题&lt;/td&gt;
&lt;td&gt;查询中的方法名直接出现在目标论文标题中&lt;/td&gt;
&lt;td&gt;&quot;LoRA是什么？&quot; → &lt;code&gt;LoRA: Low-Rank Adaptation...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;68 题&lt;/td&gt;
&lt;td&gt;方法名在标题中，但查询需要推理或跨论文对比&lt;/td&gt;
&lt;td&gt;&quot;QLoRA和LoRA在量化上有什么不同？&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard&lt;/td&gt;
&lt;td&gt;73 题&lt;/td&gt;
&lt;td&gt;查询不含方法名，或方法名不在目标论文标题中&lt;/td&gt;
&lt;td&gt;&quot;怎么用强化学习优化离散文本prompt？&quot; → &lt;code&gt;RLPrompt: Optimizing Discrete Text Prompts...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Ground Truth&lt;/strong&gt;：每道题对应一篇论文（single-label）。命名方法型论文的 ground truth 是该论文本身；描述标题型论文的 ground truth 根据论文实际贡献手动指定。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对比配置&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;配置&lt;/th&gt;
&lt;th&gt;检索方式&lt;/th&gt;
&lt;th&gt;融合策略&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pure Dense&lt;/td&gt;
&lt;td&gt;Milvus 向量 top-6&lt;/td&gt;
&lt;td&gt;无融合&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25+Dense RRF&lt;/td&gt;
&lt;td&gt;BM25(12) + Dense(12)&lt;/td&gt;
&lt;td&gt;标准 RRF 等权融合&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enhanced&lt;/td&gt;
&lt;td&gt;LLM Router 提取 targets → 4-path Weighted RRF (1.6/1.3/0.9/0.8)&lt;/td&gt;
&lt;td&gt;多路加权融合（不含 screen_papers）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;评估指标&lt;/strong&gt;：Hit@1、Hit@3、Hit@5、MRR（Mean Reciprocal Rank）、NDCG@5，按难度分层报告。同时记录每个配置的平均检索延迟。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;评测脚本&lt;/strong&gt;：&lt;code&gt;scripts/eval_retrieval.py&lt;/code&gt;，支持 &lt;code&gt;--queries-json&lt;/code&gt; 指定评测集、&lt;code&gt;--max-papers&lt;/code&gt; 控制检索数量、&lt;code&gt;--seed&lt;/code&gt; 固定随机种子以确保可复现。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python scripts/eval_retrieval.py --queries-json data/eval_queries_v3.json --max-papers 6 --seed 42
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;评测结果保存在 &lt;code&gt;data/eval_retrieval_results.json&lt;/code&gt;，包含每个配置的聚合指标和逐题详情，可用于后续分析各配置在不同难度和查询类型上的表现差异。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;整库评测结果&lt;/strong&gt;（159 题，3 配置，BM25 使用 jieba CJK 分词器）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Pure Dense&lt;/th&gt;
&lt;th&gt;BM25(jieba)+Dense RRF&lt;/th&gt;
&lt;th&gt;Enhanced (4-path)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hit@1&lt;/td&gt;
&lt;td&gt;0.956&lt;/td&gt;
&lt;td&gt;0.748&lt;/td&gt;
&lt;td&gt;0.824&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;分难度结果&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;难度&lt;/th&gt;
&lt;th&gt;题数&lt;/th&gt;
&lt;th&gt;Dense Hit@1&lt;/th&gt;
&lt;th&gt;BM25+Dense Hit@1&lt;/th&gt;
&lt;th&gt;Enhanced Hit@1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Easy&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;0.985&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;0.918&lt;/td&gt;
&lt;td&gt;0.575&lt;/td&gt;
&lt;td&gt;0.575&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;在消歧义与关系查询专项（26 题）和注入噪声对比（26 题）两个细分评测集上的表现见 11.5 消融实验。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关键发现&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Pure Dense 在整库语义匹配上几乎完美&lt;/strong&gt;（Hit@1=97.5%）。这是因为 &lt;code&gt;text-embedding-3-large&lt;/code&gt;（3072 维）在 113 篇论文的封闭域上，&lt;code&gt;paper_card&lt;/code&gt; 中的 LLM 摘要（占内容的 86%）提供了充分的语义信号。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;BM25 对中文原本完全失效&lt;/strong&gt;（Hit@1=0.176），原因是默认空格分词器将整句中文字符串视为单个 token。更换为 jieba CJK 分词器后恢复到 0.748——这是整个评测过程中最有价值的工程发现。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Enhanced 在整库评测中未超越 Pure Dense&lt;/strong&gt;（0.824 vs 0.956），因为评测集中多数查询属于&quot;方法名→论文&quot;的语义匹配，正是 Dense 的最强项。多路融合的价值不在此类查询上体现。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;11.5 检索消融实验&lt;/h3&gt;
&lt;p&gt;为进一步量化 LLM 摘要和多路融合各自的贡献，设计了两组消融实验。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验一：LLM 摘要贡献度分析&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;paper_card 的内容组成：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组成部分&lt;/th&gt;
&lt;th&gt;字符数&lt;/th&gt;
&lt;th&gt;占比&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;结构化字段（title, aliases, authors, year, tags）&lt;/td&gt;
&lt;td&gt;419&lt;/td&gt;
&lt;td&gt;14%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM/Zotero 生成的摘要&lt;/td&gt;
&lt;td&gt;1258&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;证据提示及其他&lt;/td&gt;
&lt;td&gt;1333&lt;/td&gt;
&lt;td&gt;44%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;摘要占 paper_card 总内容的 42%，是 Dense embedding 最主要的语义来源。去掉摘要后，embedding 只能基于 419 字符的结构化关键词来计算。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验二：摘要消融对比&lt;/strong&gt;（26 道消歧义+关系查询）&lt;/p&gt;
&lt;p&gt;构建一份完全去除摘要的 paper_card（&lt;code&gt;abstract_or_summary: [removed]&lt;/code&gt;），重建 Milvus 索引和 BM25 索引，在相同查询上对比：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;配置&lt;/th&gt;
&lt;th&gt;有摘要 Hit@1&lt;/th&gt;
&lt;th&gt;无摘要 Hit@1&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pure Dense&lt;/td&gt;
&lt;td&gt;0.577&lt;/td&gt;
&lt;td&gt;0.500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-13.3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25(jieba)+Dense RRF&lt;/td&gt;
&lt;td&gt;0.500&lt;/td&gt;
&lt;td&gt;0.385&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-23.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enhaced (4-path)&lt;/td&gt;
&lt;td&gt;0.308&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.538&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+75.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;结论&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;摘要对 Dense 至关重要&lt;/strong&gt;：去掉后 Hit@1 下降 13.3%，验证了 LLM 生成摘要是 Dense 高性能的前提。在面试中可将其作为独立优化点陈述——&quot;LLM 生成的 1200+ 字符摘要将 paper_card 内容扩充了 6 倍，直接贡献了 13% 的 Hit@1 提升&quot;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;多路融合在 Dense 弱化时接管&lt;/strong&gt;：无摘要时 Enhanced 从 0.308 反超到 0.538（超过 Pure Dense 的 0.500），证明 Title Anchor 和 Relation Anchor 在 embedding 信号不足时提供了关键互补。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;但在当前规模下，Dense 就是最优解&lt;/strong&gt;：159 题整库评测中，Pure Dense 的 Hit@1（0.956）在所有配置中最高，Enhanced 在任何条件下都未超越它。多路融合在更大规模、更多噪声的场景下可能有意义，但在 113 篇论文的封闭域上是过度设计。基于此实验结论，项目已将默认检索路径简化为 Dense-only，Title Anchor 和 Relation Anchor 保留为可选模块。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;BM25 jieba 修复的验证&lt;/strong&gt;：无摘要时 BM25+Dense 从 0.500 掉到 0.385（-23.1%），说明 BM25 比 Dense 更依赖摘要文本中的关键词匹配。jieba 分词让 BM25 从完全不可用（0.176）恢复到可用水平（0.748），但在消歧义场景下仍不及 Dense。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;消融实验脚本见 &lt;code&gt;scripts/benchmark_stripped.py&lt;/code&gt;，结果保存在 &lt;code&gt;data/eval_ablation_results.json&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;11.6 架构边界测试&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;test_review_architecture_boundaries.py&lt;/code&gt; 专门测试模块间的架构边界，确保 domain models 不依赖基础设施、service 层不循环依赖、Agent mixins 的正交性等约束。&lt;/p&gt;
&lt;h2&gt;12. 部署与运维&lt;/h2&gt;
&lt;h3&gt;12.1 systemd 服务&lt;/h3&gt;
&lt;p&gt;生产部署使用 systemd 管理进程。服务定义在 &lt;code&gt;deploy/systemd/pdf-rag-agent-v4.service&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=PDF RAG Agent V4
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/owen/pdf-rag-agent-v4
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/ubuntu/miniconda3/envs/zotero-paper-rag/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8001
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务监听 &lt;code&gt;127.0.0.1:8001&lt;/code&gt;，通过 Nginx 反向代理暴露到公网。&lt;code&gt;Restart=always&lt;/code&gt; 和 &lt;code&gt;RestartSec=3&lt;/code&gt; 确保进程异常退出后 3 秒自动重启。&lt;/p&gt;
&lt;h3&gt;12.2 环境变量&lt;/h3&gt;
&lt;p&gt;配置通过 &lt;code&gt;Settings&lt;/code&gt;（Pydantic BaseSettings, &lt;code&gt;app/core/config.py&lt;/code&gt;）管理，从 &lt;code&gt;.env&lt;/code&gt; 文件和环境变量读取。当前实际部署配置（&lt;code&gt;/home/ubuntu/owen/pdf-rag-agent-v4/.env&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ── Chat Model（OpenAI 兼容协议）──
CHAT_MODEL=deepseek-v4-flash
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.deepseek.com/v1

# ── Embeddings（DeepSeek 不支持 embedding，走 Qihai 网关）──
EMBEDDING_MODEL=text-embedding-3-large
EMBEDDING_BASE_URL=https://api.qhaigc.net/v1
EMBEDDING_API_KEY=sk-xxx

# ── VLM ──
VLM_MODEL=gpt-4.1-mini

# ── Web Search ──
TAVILY_API_KEY=tvly-xxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键配置项说明：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;环境变量&lt;/th&gt;
&lt;th&gt;Settings 字段&lt;/th&gt;
&lt;th&gt;当前值&lt;/th&gt;
&lt;th&gt;默认值&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHAT_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chat_model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek-v4-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gpt-4o-mini&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Chat 模型名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OPENAI_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openai_api_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sk-xxx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Chat + VLM 的 API key（也支持 &lt;code&gt;QIHANG_API&lt;/code&gt; alias）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OPENAI_BASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openai_base_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.deepseek.com/v1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.openai.com/v1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Chat + VLM 的 Base URL（也支持 &lt;code&gt;QIHANG_BASE_URL&lt;/code&gt; alias）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EMBEDDING_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;embedding_api_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sk-xxx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Embedding API key（独立字段，fallback 到 &lt;code&gt;openai_api_key&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EMBEDDING_BASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;embedding_base_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.qhaigc.net/v1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.openai.com/v1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Embedding Base URL（也支持 &lt;code&gt;EMBEDDING_BASE&lt;/code&gt; alias）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EMBEDDING_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;embedding_model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text-embedding-3-large&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同&lt;/td&gt;
&lt;td&gt;Embedding 模型名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VLM_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vlm_model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gpt-4.1-mini&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同&lt;/td&gt;
&lt;td&gt;Vision 模型名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;embedding_fallback_model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同上&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text-embedding-3-small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同&lt;/td&gt;
&lt;td&gt;Embedding 降级模型（1536 维）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MILVUS_URI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;milvus_uri&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localhost:19530&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同&lt;/td&gt;
&lt;td&gt;Milvus 连接地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TAVILY_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tavily_api_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tvly-xxx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Web Search API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADMIN_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;admin_api_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;空&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;管理员 key（空=禁用敏感接口）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;注意：Chat 和 VLM 共用同一个 &lt;code&gt;openai_api_key&lt;/code&gt; + &lt;code&gt;openai_base_url&lt;/code&gt;，而 Embedding 使用独立的 &lt;code&gt;embedding_api_key&lt;/code&gt; + &lt;code&gt;embedding_base_url&lt;/code&gt;（因为 DeepSeek 不支持 embedding，需要走 Qihai 网关）。&lt;/p&gt;
&lt;h3&gt;12.3 服务端口与路由&lt;/h3&gt;
&lt;p&gt;FastAPI 服务监听 8001 端口，主要路由：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/&lt;/code&gt; → 直接返回 index.html&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/v4&lt;/code&gt; / &lt;code&gt;/v5&lt;/code&gt; → 前端页面 &lt;code&gt;index.html&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/v1/health&lt;/code&gt; → 健康检查&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/v1/library&lt;/code&gt; → 论文库列表&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/v1/chat&lt;/code&gt; → 普通问答（一次性返回）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/v1/chat/stream&lt;/code&gt; → SSE 流式问答&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/v1/ingest/rebuild&lt;/code&gt; → 索引重建（需 admin key）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/v1/citations/preview&lt;/code&gt; → 引用预览&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/v1/tools/proposals&lt;/code&gt; → 动态工具提案管理&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/metrics&lt;/code&gt; → Prometheus metrics（可选）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;12.4 安全控制&lt;/h3&gt;
&lt;p&gt;安全控制通过 &lt;code&gt;app/core/security.py&lt;/code&gt; 实现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;require_admin_access()&lt;/code&gt;：检查 &lt;code&gt;ADMIN_API_KEY&lt;/code&gt; 是否已配置，并根据请求头中的 &lt;code&gt;X-API-Key&lt;/code&gt; 或 &lt;code&gt;Authorization: Bearer&lt;/code&gt; 校验管理员身份。如果未配置 admin key，返回 503 禁用敏感接口。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;require_pdf_access()&lt;/code&gt;：检查 &lt;code&gt;PDF_ACCESS_KEY&lt;/code&gt; 是否已配置并校验，防止未授权访问本地 PDF 文件。&lt;/li&gt;
&lt;li&gt;CORS 限制：通过 &lt;code&gt;settings.cors_allow_origins&lt;/code&gt; 配置允许跨域的前端来源，未配置时默认不启用 CORS。&lt;/li&gt;
&lt;li&gt;输入安全：&lt;code&gt;prompt_safety.py&lt;/code&gt; 对用户输入做基本的注入检测和长度限制。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;12.5 日志与监控&lt;/h3&gt;
&lt;p&gt;日志通过 &lt;code&gt;app/core/logging.py&lt;/code&gt; 的 &lt;code&gt;setup_logging()&lt;/code&gt; 初始化，使用 Python 标准 &lt;code&gt;logging&lt;/code&gt; 模块输出 JSON 格式日志，每条日志包含 timestamp、level、logger、message 和异常信息。&lt;/p&gt;
&lt;p&gt;Prometheus metrics（可选）通过 &lt;code&gt;prometheus_fastapi_instrumentator&lt;/code&gt; 暴露，包含 HTTP 请求数、响应延迟分布、状态码分布、Python GC 统计、进程内存和 CPU 使用率、文件描述符数量等指标。生产环境使用 Grafana 面板展示这些指标。&lt;/p&gt;
&lt;h2&gt;13. 项目难点与迭代&lt;/h2&gt;
&lt;h3&gt;13.1 从普通 RAG 到 Agent&lt;/h3&gt;
&lt;p&gt;最初的设想是一个简单的&quot;搜索 PDF + 问 LLM&quot;系统。但实际使用中发现，论文问答需要的远不止检索+生成：用户可能用缩写指代论文、可能在追问中省略上下文、可能需要精确的公式或指标数值而不是模糊总结、可能需要比较不同论文的结果。这些需求迫使系统从一个简单的 RAG pipeline 演进成一个有意图理解、工具规划、多步执行、验证回退能力的 Agent 系统。&lt;/p&gt;
&lt;h3&gt;13.2 从 relation 分类到结构化意图&lt;/h3&gt;
&lt;p&gt;早期版本的意图识别只输出一个 &lt;code&gt;relation&lt;/code&gt; 字符串，后续模块各自从原始 query 中重新提取信息。这导致不同模块对同一问题的理解不一致。引入 &lt;code&gt;QueryContract&lt;/code&gt; 后，所有后续模块都从同一个结构化的意图对象中读取 targets、requested_fields、required_modalities、answer_shape、precision_requirement 等字段，消除了信息提取的不一致性。&lt;/p&gt;
&lt;h3&gt;13.3 从固定流水线到 tool loop&lt;/h3&gt;
&lt;p&gt;最早的实现是固定的线性流水线：识别意图 → 检索 → 抽 claim → 验证 → 回答。但实际场景中，有些问题需要多次检索（先找论文、再找公式、再找表格），有些需要验证失败后 retry，有些需要在检索过程中发现新目标。引入 tool loop 机制后，Agent 可以根据当前状态动态决定下一步动作，planner 可以在运行时调整工具序列，runtime 支持有限步数内的自动回退和重试。&lt;/p&gt;
&lt;h3&gt;13.4 多轮上下文绑定&lt;/h3&gt;
&lt;p&gt;多轮对话的难点在于判断新问题与旧问题的关系。用户可能说&quot;那它的实验结果呢&quot;，系统需要知道&quot;它&quot;指的是上一轮讨论的那篇论文；用户也可能说&quot;不对，我要的是 DPO 原论文&quot;，系统需要识别这是对上一轮的纠正。通过 &lt;code&gt;SessionContext&lt;/code&gt; 中的 active_research、topic_signature 和 &lt;code&gt;FollowupRoutingMixin&lt;/code&gt; 的上下文分析，系统能够在不同 followup 模式下做出正确响应。&lt;code&gt;is_negative_correction_query()&lt;/code&gt; 函数专门检测否定纠正类问题，触发上下文清除和重新检索。&lt;/p&gt;
&lt;h3&gt;13.5 公式、图表与精确指标&lt;/h3&gt;
&lt;p&gt;这是论文问答中最具挑战性的部分。公式不是普通文本，LaTeX 和 Unicode 数学符号的检索需要特殊的 token 权重和匹配逻辑。图表需要 VLM 理解，但 VLM 的调用成本高、延迟大。精确指标（如 Accuracy=94.2%）要求系统不能做语义近似，必须精确匹配证据中的数值。&lt;/p&gt;
&lt;p&gt;解决方案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;公式检索使用 &lt;code&gt;retrieval_formula_token_weights&lt;/code&gt; 和 &lt;code&gt;FORMULA_HINT_RE&lt;/code&gt; 做专门加权&lt;/li&gt;
&lt;li&gt;图表理解采用按需调用 VLM 的策略，先用文本信号（&lt;code&gt;figure_signal_score()&lt;/code&gt;）判断页面是否包含目标图表，再决定是否渲染并调用 VLM&lt;/li&gt;
&lt;li&gt;指标验证使用 &lt;code&gt;verify_metric_value_lookup_claims()&lt;/code&gt; 做精确数值比对，允许小范围舍入误差但不接受语义近似&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;13.6 从人工澄清到 LLM-judge 自动消歧&lt;/h3&gt;
&lt;p&gt;早期版本中，只要系统发现一个缩写或实体可能对应多篇论文，就会直接进入 &lt;code&gt;ask_human&lt;/code&gt;，把候选项交给用户选择。这种方式很安全，但在 DPO 这类&quot;原论文明显存在，其他论文只是引用或应用&quot;的场景下，会打断用户体验。最新版本加入了 &lt;code&gt;DisambiguationJudgeDecision&lt;/code&gt;，让 LLM-judge 在候选 metadata、snippet、paper summary 和 ranking signals 的基础上判断是否可以自动绑定候选。只有当 judge 返回 &lt;code&gt;auto_resolve&lt;/code&gt; 且置信度不低于 0.85 时，系统才自动选择论文；如果置信度不足，则仍保留人工澄清，并可把高分候选标记为推荐项。这样既减少了不必要的追问，也避免在低置信度场景下盲目猜测。&lt;/p&gt;
&lt;h3&gt;13.7 从单文件到分层架构&lt;/h3&gt;
&lt;p&gt;项目最早的 &lt;code&gt;agent.py&lt;/code&gt; 是一个超过 2000 行的单文件。随着功能增加，单文件变得难以维护：修改一个 solver 可能影响 planner，调试一个 bug 需要在同一文件中跳转数百行。重构过程经历了多次迭代：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一轮：把 retrieval、library、session_store 拆成独立服务模块&lt;/li&gt;
&lt;li&gt;第二轮：把 Agent 核心逻辑拆分为 planner、runtime、tools、events、loop 等模块&lt;/li&gt;
&lt;li&gt;第三轮：引入 Mixin 模式，把 answer_composer、claim_verifier、entity_definition、followup_routing、solver_pipeline 五大能力正交拆分。&lt;code&gt;ResearchAssistantAgent&lt;/code&gt; 通过多重继承组合这些 Mixin，每个 Mixin 只关心自己的领域&lt;/li&gt;
&lt;li&gt;第四轮：把 claims、answers、intents、contracts、planning 按领域拆成独立子包，每个 &lt;code&gt;app/services/&amp;lt;domain&amp;gt;/&lt;/code&gt; 子包有明确的职责边界&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在的目录结构清晰反映了领域边界：&lt;code&gt;__init__.py&lt;/code&gt; 中只做 re-export，模块间的依赖通过构造函数注入而非硬编码 import。&lt;/p&gt;
&lt;h3&gt;13.8 Dynamic Tools 扩展机制&lt;/h3&gt;
&lt;p&gt;后续发现有些工具需求不适合写死在核心代码中（如特定的 SQL 查询、自定义的数据分析工具）。引入动态工具机制后，用户可以通过 JSON manifest 文件注册新工具，Agent 的 Planner 和 Runtime 会自动发现并集成这些工具。&lt;code&gt;ToolProposalSandboxRequest&lt;/code&gt; 和 &lt;code&gt;ToolProposalTransitionRequest&lt;/code&gt; 这两个 API schema 支持工具提案的生命周期管理——从提案创建、沙盒测试到正式启用。&lt;/p&gt;
&lt;h3&gt;13.9 Compound Query 复合查询&lt;/h3&gt;
&lt;p&gt;对于包含多个独立子问题的复杂查询（如&quot;比较 DPO 和 PPO 的公式，并分析各自的优缺点&quot;），系统通过 &lt;code&gt;run_compound_query_if_needed()&lt;/code&gt; 将问题分解为多个子任务，逐个执行后合并结果。&lt;code&gt;compound.py&lt;/code&gt; 中的分解逻辑使用 LLM 判断是否需要拆分以及如何拆分，每个子任务独立走完整的 Agent 流程，最后通过 &lt;code&gt;CompoundTaskResult&lt;/code&gt; 汇总。&lt;/p&gt;
&lt;h2&gt;14. 总结与后续优化&lt;/h2&gt;
&lt;h3&gt;14.1 项目总结&lt;/h3&gt;
&lt;p&gt;PDF-RAG-Agent V5是一个从真实论文研究需求出发构建的智能助手系统。它的核心价值体现在几个方面：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;分层架构清晰&lt;/strong&gt;：API 层、Agent 层、服务层、数据层各司其职，通过 domain models 传递状态，避免了&quot;到处传 dict&quot;的混乱&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检索设计务实&lt;/strong&gt;：两级索引（论文级 + 证据级）+ Dense-only 默认检索（可选 BM25/Title Anchor）+ 场景化加权，解决了通用 RAG 在论文场景下的召回精度问题&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agent 链路完整&lt;/strong&gt;：Intents → Contract → Plan → Tool Loop → Solver → Verifier → Composer 的七阶段链路，每一阶段都有明确的输入输出和失败处理&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可观察性强&lt;/strong&gt;：SSE 流式事件 + Runtime 面板 + trace 持久化，让 Agent 的每一步推理都可追踪、可调试&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;测试覆盖广&lt;/strong&gt;：80+ 个测试文件覆盖几乎所有模块，StubModelClients 让测试不依赖外部 API&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;持续演进&lt;/strong&gt;：从单文件到分层架构、从固定流水线到 tool loop、从人工澄清到 LLM-judge 自动消歧、从纯 RAG 到可以处理公式/图表/指标的论文 Agent，项目在整个开发过程中不断根据实际使用反馈迭代&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;14.2 后续优化方向&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Streaming 工具执行&lt;/strong&gt;：目前 tool loop 中的工具是同步执行的，后续可以让 &lt;code&gt;search_corpus&lt;/code&gt; 和 &lt;code&gt;compose&lt;/code&gt; 在工具内部流式产出结果，减少用户感知的首 token 延迟&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;更智能的 retry 策略&lt;/strong&gt;：当前 retry 预算固定为 1，可以根据 verification report 中 missing_fields 的具体类型（是缺公式还是缺指标还是缺论文）做更精细的 retry 决策&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;论文库增量更新&lt;/strong&gt;：当前 &lt;code&gt;ingest rebuild&lt;/code&gt; 是全量重建，后续可以支持增量模式——只处理新增或修改的论文，大幅缩短入库时间&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多库联合检索&lt;/strong&gt;：当前只支持单个 Zotero 库，后续可以支持多个 Zotero profile 或跨库检索&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Answer quality 自动化回归&lt;/strong&gt;：在 eval cases 基础上建立 answer quality 的自动化回归测试，每次改动后自动跑 eval 并对比分数，防止性能退化&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;持久化 learnings 的自动更新&lt;/strong&gt;：当前 persistent_learnings 需要手动维护，后续可以让 Agent 在每次研究后自动提取关键发现并写入&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨论文对比能力增强&lt;/strong&gt;：当前 compound query 支持基本的子任务分解，但跨论文的深度对比（如方法对比、结果对比、消融实验对比）还可以做得更精细&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VLM 调用策略优化&lt;/strong&gt;：当前 VLM 调用是按需的，但判断&quot;是否需要 VLM&quot;本身也需要一次 LLM 调用。后续可以通过更强的文本信号预处理减少不必要的 VLM 调度判断&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>AIOS-NP 项目档案：从 AIOS 内核到在线新闻系统</title><link>https://owen571.top/posts/lab/aios-newsroom/00-aios-newsroom-%E6%80%BB%E8%A7%88-%E4%BB%8E%E6%AF%94%E8%B5%9B-demo-%E5%88%B0-agent-ecosystem/</link><guid isPermaLink="true">https://owen571.top/posts/lab/aios-newsroom/00-aios-newsroom-%E6%80%BB%E8%A7%88-%E4%BB%8E%E6%AF%94%E8%B5%9B-demo-%E5%88%B0-agent-ecosystem/</guid><description>本文作为 AIOS Newsroom 的项目档案，系统整理 AIOS 架构、内核机制、新闻工作流、能力接入与在线化实现。</description><pubDate>Tue, 21 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;项目展示页位于 &lt;a href=&quot;/lab/aios-newsroom/&quot;&gt;AIOS Newsroom&lt;/a&gt;。这篇文章不再保留原来的概览模板，而是直接使用当前项目的完整笔记，作为展示页里的正式项目档案。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;背景&lt;/h1&gt;
&lt;h2&gt;项目由来&lt;/h2&gt;
&lt;p&gt;AIOS-NP 是由比赛项目更改而来，发布在线上的项目，并已经公开了MCP。该项目初版获 &lt;strong&gt;第二届中国研究生操作系统开源创新大赛&lt;/strong&gt; 国家三等奖。总体而言，是一个基于 workflow 的多 Agent 新闻生成流水线，当前已经演化为 &lt;code&gt;hot_api -&amp;gt; sort -&amp;gt; search -&amp;gt; generate -&amp;gt; review -&amp;gt; report&lt;/code&gt; 六阶段结构。&lt;/p&gt;
&lt;h2&gt;AIOS背景&lt;/h2&gt;
&lt;p&gt;以下来自于比赛使其调研的笔记修改：&lt;/p&gt;
&lt;h3&gt;(1) 必要性&lt;/h3&gt;
&lt;p&gt;往往需要在同一个设备上运行很多的Agent. ( 即使是Single-Agent也可能在内部分出许多Sub-Agent). 那么底层的LLM就可能要满足很多Agent同时的请求, 如果不做管理, 如果同一个Agent一直密集向LLM发送请求, 那么就会让其他Agent拿不到资源, 看上去像死机一样.&lt;/p&gt;
&lt;p&gt;这个过程实际上很容易让人联想到操作系统的功能 -- 它不仅提供硬件接口对软件的接口, 同时也会按照设计的算法来调度进程, 保证多进程系统依然可以异步有效工作.&lt;/p&gt;
&lt;p&gt;于是类似于传统OS, 一个&lt;strong&gt;基于开源大模型Agent的操作系统, AIOS&lt;/strong&gt; 就诞生了.&lt;/p&gt;
&lt;h3&gt;(2) 对比传统OS&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Faios-vs-os-overview.CLvsd-Vd.png&amp;amp;w=1174&amp;amp;h=921&amp;amp;f=webp&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;AIOS的核心是一个或几个大模型, 中间层设计了AIOS的SDK, 来帮助Agent Developer更好构建他们的Agents, 而上层则是会跑各种Agent的应用 ( &lt;strong&gt;AAPs, Agent Applications&lt;/strong&gt; ).&lt;/p&gt;
&lt;p&gt;以下两张图片分别是传统OS的架构和AIOS架构的示意图.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Ftraditional-os-architecture.CZZlaRCO.png&amp;amp;w=1286&amp;amp;h=910&amp;amp;f=webp&quot; alt=&quot;&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Faios-architecture-diagram.COH-rrT6.png&amp;amp;w=1394&amp;amp;h=830&amp;amp;f=webp&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;我们可以发现, AIOS可以很容易类比到传统OS的生态, 由此我们可以体会到AIOS的设计哲学, 如下表:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Faios-os-philosophy-table.DLNKodRB.png&amp;amp;w=1210&amp;amp;h=982&amp;amp;f=webp&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;两者的发展历程也具有相似性, 只不过AIOS的发展要比OS发展的迅速非常多, 如下图&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Faios-os-evolution.CUXg1e7A.png&amp;amp;w=1701&amp;amp;h=764&amp;amp;f=webp&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;AIOS架构&lt;/h1&gt;
&lt;h2&gt;1. 总览&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;AIOS: AI Agent Operating System&lt;/strong&gt; &lt;a href=&quot;https://github.com/agiresearch/AIOS/tree/main&quot;&gt;Github&lt;/a&gt;&lt;a href=&quot;https://arxiv.org/abs/2403.16971&quot;&gt;论文&lt;/a&gt;, 它将大型语言模型 （LLM） 嵌入到作系统中，并促进基于 LLM 的 AI 代理的开发和部署. AIOS 旨在解决基于 LLM 的代理开发和部署过程中的问题（例如，调度、上下文切换、内存管理、存储管理、工具管理、代理 SDK 管理等）, 为代理开发人员和代理用户提供更好的 AIOS-Agent 生态系统. AIOS 包括 AIOS 内核（此AIOS存储库）和 AIOS SDK（&lt;strong&gt;Cerebrum&lt;/strong&gt; 存储库), AIOS 支持 Web UI 和终端 UI. 它的具体框架如下文所展示.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Faios-framework.CZ9J8W2j.png&amp;amp;w=1565&amp;amp;h=915&amp;amp;f=webp&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. Cerebrum&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Cerebrum: Agent SDK for AIOS&lt;/strong&gt; &lt;a href=&quot;https://github.com/agiresearch/Cerebrum&quot;&gt;Github&lt;/a&gt; 专为代理用户和开发人员设计, 使他们能够通过与 AIOS 内核交互来构建和运行代理应用程序. 但要注意, 我当前项目中的 &lt;code&gt;cerebrum/&lt;/code&gt; 并不是上游 Cerebrum 仓库的完整拷贝, 而是一个&lt;strong&gt;嵌入到 AIOS-NP 中、经过裁剪和本地化适配后的 SDK 子集&lt;/strong&gt;. 因此这里不再照抄上游完整目录树, 而是按&lt;strong&gt;当前仓库真实结构&lt;/strong&gt;来理解:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AIOS-NP/cerebrum
|-- __init__.py
|-- commands
|   |-- download_agent.py
|   |-- download_tool.py
|   |-- list_agenthub_agents.py
|   |-- list_available_llms.py
|   |-- list_local_agents.py
|   |-- list_local_tools.py
|   |-- list_toolhub_tools.py
|   |-- run_agent.py
|   |-- upload_agent.py
|   `-- upload_tool.py
|-- community
|   `-- adapter
|       |-- adapter.py
|       |-- autogen_adapter.py
|       |-- interpreter_adapter.py
|       `-- metagpt_adapter.py
|-- config
|   |-- config.yaml
|   `-- config_manager.py
|-- interface
|   `-- __init__.py
|-- llm
|   |-- apis.py
|   `-- layer.py
|-- manager
|   |-- agent.py
|   |-- package.py
|   `-- tool.py
|-- memory
|   |-- __init__.py
|   |-- apis.py
|   `-- layer.py
|-- storage
|   |-- apis.py
|   `-- layer.py
|-- tool
|   |-- apis.py
|   |-- base.py
|   |-- core
|   `-- layer.py
|-- utils
|   |-- browser.py
|   |-- communication.py
|   |-- manager.py
|   |-- packages.py
|   |-- run_agent.py
|   `-- utils.py
|-- pyproject.toml
`-- requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 SDK 依然是实现 AIOS 整个体系的关键, 但对于当前项目而言, 更重要的不是背完整仓库树, 而是明确它在本地项目中承担了哪几类职责. 我把它理解为: &lt;strong&gt;一层面向 AIOS Kernel 的 Query / Response SDK&lt;/strong&gt;, 上面再由 &lt;code&gt;apps/news_app&lt;/code&gt; 这层业务应用去组织真正的新闻流水线.&lt;/p&gt;
&lt;h3&gt;(1) 当前项目中的 cerebrum&lt;/h3&gt;
&lt;p&gt;这个文件夹被整体当作可安装的包使用, 即已经通过 &lt;code&gt;pip install -e .&lt;/code&gt; 安装, 并在本地脚本和业务代码中直接导入. 对当前新闻项目来说, 它的主要作用如下:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;commands&lt;/code&gt;&lt;br /&gt;
存放一组命令行入口, 用于 agent / tool 的上传、下载、列举与运行. 这些脚本体现了 Cerebrum 作为 SDK 的“工具化外壳”.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;community&lt;/code&gt;&lt;br /&gt;
用于适配外部多智能体框架. 当前项目中可以看到 &lt;code&gt;autogen_adapter.py&lt;/code&gt;、&lt;code&gt;interpreter_adapter.py&lt;/code&gt;、&lt;code&gt;metagpt_adapter.py&lt;/code&gt; 等文件, 说明它仍保留了对这些框架的兼容能力. 但对我当前的新闻业务主线而言, 这层已经不是核心.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;config&lt;/code&gt;&lt;br /&gt;
负责保存并读取 Cerebrum 向 AIOS Kernel 发请求时依赖的配置, 最关键的是内核地址、模型地址和相关运行参数. 这层一定要和 &lt;code&gt;aios/config&lt;/code&gt; 保持信息流一致.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;interface&lt;/code&gt;&lt;br /&gt;
这一层原本更偏向于和 hub 对接的接口封装. 在当前仓库里已经被明显收缩, 实际上主要只剩下 &lt;code&gt;AutoTool&lt;/code&gt; 这种较薄的封装, 用来从 Tool Hub 或本地加载工具.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;llm&lt;/code&gt;&lt;br /&gt;
这是最重要的 API 层之一.&lt;br /&gt;
&lt;code&gt;apis.py&lt;/code&gt; 中定义了 &lt;code&gt;LLMQuery&lt;/code&gt; 和 &lt;code&gt;LLMResponse&lt;/code&gt;, 负责描述 LLM 请求和返回结构; 同时实现了 &lt;code&gt;llm_chat&lt;/code&gt;、&lt;code&gt;llm_chat_with_json_output&lt;/code&gt;、&lt;code&gt;llm_chat_with_tool_call_output&lt;/code&gt;、&lt;code&gt;llm_call_tool&lt;/code&gt;、&lt;code&gt;llm_operate_file&lt;/code&gt; 等核心函数.&lt;br /&gt;
&lt;code&gt;layer.py&lt;/code&gt; 则定义了和 LLM 推理层参数有关的数据结构.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;manager&lt;/code&gt;&lt;br /&gt;
负责 agent / tool 的打包、上传、下载、缓存、动态加载和版本管理. 这层是整个 SDK 插件化能力的基础, 但在阅读当前新闻流水线时不是优先关注对象.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;memory&lt;/code&gt;&lt;br /&gt;
与 &lt;code&gt;llm&lt;/code&gt; 结构类似, 但面向记忆管理.&lt;br /&gt;
&lt;code&gt;apis.py&lt;/code&gt; 中定义了 &lt;code&gt;create_memory&lt;/code&gt;、&lt;code&gt;get_memory&lt;/code&gt;、&lt;code&gt;update_memory&lt;/code&gt;、&lt;code&gt;delete_memory&lt;/code&gt;、&lt;code&gt;search_memories&lt;/code&gt; 和 &lt;code&gt;create_agentic_memory&lt;/code&gt; 等方法, 用于给智能体提供长期记忆能力.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;storage&lt;/code&gt;&lt;br /&gt;
面向存储操作的 API 封装.&lt;br /&gt;
&lt;code&gt;apis.py&lt;/code&gt; 中实现了 &lt;code&gt;mount&lt;/code&gt;、&lt;code&gt;retrieve_file&lt;/code&gt;、&lt;code&gt;create_file&lt;/code&gt;、&lt;code&gt;create_dir&lt;/code&gt;、&lt;code&gt;rollback_file&lt;/code&gt; 和 &lt;code&gt;share_file&lt;/code&gt; 等能力, 用于将文件操作统一成对 AIOS Kernel 的请求.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;tool&lt;/code&gt;&lt;br /&gt;
与 &lt;code&gt;llm&lt;/code&gt; 类似, 是工具调用这一类 syscall 的 SDK 封装.&lt;br /&gt;
&lt;code&gt;apis.py&lt;/code&gt; 中定义了 &lt;code&gt;ToolQuery&lt;/code&gt;、&lt;code&gt;ToolResponse&lt;/code&gt; 和 &lt;code&gt;call_tool&lt;/code&gt;; &lt;code&gt;base.py&lt;/code&gt; 定义了 &lt;code&gt;BaseTool&lt;/code&gt; 及相关工具基类; &lt;code&gt;layer.py&lt;/code&gt; 负责工具层数据结构; &lt;code&gt;core/&lt;/code&gt; 下则存放了本地工具实现.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;utils&lt;/code&gt;&lt;br /&gt;
这是通用工具库, 提供通信、浏览器辅助、包管理、脚本运行等支撑函数. 这里不用一开始就逐个读懂, 先知道它是底层支撑层即可.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要强调的是, &lt;strong&gt;上游 Cerebrum 仓库中常见的 &lt;code&gt;benchmarks&lt;/code&gt;、&lt;code&gt;docs&lt;/code&gt;、&lt;code&gt;tests&lt;/code&gt;、&lt;code&gt;example&lt;/code&gt; 等目录, 在我当前这个本地项目中并不是主阅读对象, 甚至有些已经不存在&lt;/strong&gt;. 因此后续写项目笔记时, 应该始终以当前仓库中的实际目录为准, 不再把“上游完整仓库结构”和“本地裁剪后的可运行版本”混为一谈.&lt;/p&gt;
&lt;h3&gt;(2) 当前项目实际使用的 Cerebrum APIs&lt;/h3&gt;
&lt;p&gt;Cerebrum 在设计上提供了 &lt;code&gt;llm / memory / storage / tool&lt;/code&gt; 四类 API。对当前 AIOS-NP 新闻项目而言，最重要的不是把这些函数名逐个背下来，而是先明确：&lt;strong&gt;它们分别对应内核中的哪一类 Query / Response 协议。&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm.apis&lt;/code&gt; -&amp;gt; &lt;code&gt;LLMQuery / LLMResponse&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;memory.apis&lt;/code&gt; -&amp;gt; &lt;code&gt;MemoryQuery / MemoryResponse&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;storage.apis&lt;/code&gt; -&amp;gt; &lt;code&gt;StorageQuery / StorageResponse&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tool.apis&lt;/code&gt; -&amp;gt; &lt;code&gt;ToolQuery / ToolResponse&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，Cerebrum 在当前项目中的角色，不只是“提供一些方便调用的 Python 函数”，而是把“向 AIOS 内核发请求”这件事统一封装成四类结构化 API。下面按当前项目实际使用情况来理解。&lt;/p&gt;
&lt;h4&gt;1. LLM API&lt;/h4&gt;
&lt;p&gt;这一组是当前新闻生成链路里使用最频繁的一层，对应的底层协议是 &lt;code&gt;LLMQuery -&amp;gt; LLMResponse&lt;/code&gt;。典型接口包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from cerebrum.llm.apis import llm_chat
from cerebrum.llm.apis import llm_chat_with_json_output
from cerebrum.llm.apis import llm_call_tool
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm_chat&lt;/code&gt; 是最常用的接口，用于标题、摘要、正文、专家评审、总览生成等文本生成任务。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;llm_chat_with_json_output&lt;/code&gt; 主要用于需要结构化输出的场景，比如 &lt;code&gt;sort_agent&lt;/code&gt; 对热榜进行分类整理时。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;llm_call_tool&lt;/code&gt; 保留了“由 LLM 决定调用哪个工具”的能力，但当前新闻项目的主流程里并不依赖它作为默认路径。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，LLM API 在当前项目中的作用，可以概括为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;统一构造 LLM 请求&lt;/li&gt;
&lt;li&gt;屏蔽内核 &lt;code&gt;/query&lt;/code&gt; 的细节&lt;/li&gt;
&lt;li&gt;让上层 agent 能围绕 prompt 和结果来组织业务逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2. Memory API&lt;/h4&gt;
&lt;p&gt;这一组对应 &lt;code&gt;MemoryQuery -&amp;gt; MemoryResponse&lt;/code&gt;。当前项目虽然确实在使用 Cerebrum 的记忆接口，但主要不是在每个 agent 文件里直接调用，而是通过 &lt;code&gt;runtime_support/memory.py&lt;/code&gt; 再做了一层业务封装。当前实际会用到的接口主要是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from cerebrum.memory.apis import create_memory
from cerebrum.memory.apis import create_agentic_memory
from cerebrum.memory.apis import search_memories
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些接口在新闻项目中的作用，主要是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;将工作流中的编辑决策写入长期记忆&lt;/li&gt;
&lt;li&gt;在后续生成或出报前检索相似题材&lt;/li&gt;
&lt;li&gt;为当前 gate 提供历史通过/拒绝的参考&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此它在当前项目中的定位不是“通用聊天记忆”，而是&lt;strong&gt;服务于新闻质量控制的编辑决策记忆层&lt;/strong&gt;。&lt;/p&gt;
&lt;h4&gt;3. Storage API&lt;/h4&gt;
&lt;p&gt;这一组对应 &lt;code&gt;StorageQuery -&amp;gt; StorageResponse&lt;/code&gt;。存储接口现在也还在使用，但同样不是业务代码直接大面积调用，而是通过 &lt;code&gt;runtime_support/artifacts.py&lt;/code&gt; 的 &lt;code&gt;ArtifactStore&lt;/code&gt; 抽象统一管理。当前实际接入的接口主要包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from cerebrum.storage.apis import mount
from cerebrum.storage.apis import create_dir
from cerebrum.storage.apis import write_file
from cerebrum.storage.apis import read_file
from cerebrum.storage.apis import list_dir
from cerebrum.storage.apis import delete_file
from cerebrum.storage.apis import delete_dir
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些 API 的作用是把中间产物和最终结果的文件操作，统一包装成对 AIOS Kernel 的存储请求。不过在当前项目中，这层还有本地文件后端作为 fallback，因此它体现的是“可接入 AIOS 原生存储能力”，而不是整个新闻系统对它强依赖。&lt;/p&gt;
&lt;h4&gt;4. Tool API&lt;/h4&gt;
&lt;p&gt;这一组对应 &lt;code&gt;ToolQuery -&amp;gt; ToolResponse&lt;/code&gt;。当前项目里最重要的入口是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from cerebrum.tool.apis import call_tool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的作用是把“执行某个工具”这件事包装成独立 syscall。当前新闻流水线已经把 &lt;code&gt;hot_api&lt;/code&gt; 和 &lt;code&gt;web_search&lt;/code&gt; 这类叶子能力下沉为本地工具，因此 Tool API 在当前项目中的定位很清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;承接被下沉的叶子能力&lt;/li&gt;
&lt;li&gt;让这些能力进入 AIOS runtime 的 ToolManager 调度链&lt;/li&gt;
&lt;li&gt;为以后更 agentic 的工具使用方式留出扩展空间&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;5. 当前保留但不是主流程重点的 API&lt;/h4&gt;
&lt;p&gt;除了上面这些当前仍在使用的接口之外，Cerebrum 里还保留了一些能力，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from cerebrum.llm.apis import llm_chat_with_tool_call_output
from cerebrum.llm.apis import llm_operate_file
from cerebrum.storage.apis import retrieve_file
from cerebrum.storage.apis import rollback_file
from cerebrum.storage.apis import share_file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些接口在仓库中依然存在，说明 AIOS 的能力边界依旧比较完整；但对&lt;strong&gt;当前新闻项目的主业务流水线&lt;/strong&gt;来说，它们并不是最值得优先展开的部分。因此在项目介绍里，我更倾向于把它们放在“能力储备”或“扩展路径”的位置，而不是当成当前项目主链路的核心实现。&lt;/p&gt;
&lt;h2&gt;3. AIOS Kernel&lt;/h2&gt;
&lt;p&gt;作为和Cerebrum直接沟通的部分，同时也是与大模型直接沟通的部分, 这一部分是用来处理各种syscall的关键.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Faios-kernel-relationship.C6sOLhp9.png&amp;amp;w=1475&amp;amp;h=831&amp;amp;f=webp&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;AIOS kernel中包含一个系统核心所需要的各种方法, 它暴露了一系列接口来接受 Query, 再通过 SystemCall 绑定 Scheduler, 按照一定规则与 LLM / Memory / Storage / Tool 等模块交互, 最终得到结果返回. 所以要想真正跑起 AIOS, 就必须先通过 runtime 里的 launch 脚本启动核心. 对当前新闻项目来说, Kernel 更像是整个底层 syscall 能力的统一入口, 其上再由 Cerebrum 负责封装请求, 最后由 &lt;code&gt;apps/news_app&lt;/code&gt; 组织成具体业务流水线.&lt;/p&gt;
&lt;h2&gt;4. LSFS&lt;/h2&gt;
&lt;p&gt;LSFS 即 &lt;em&gt;LLM-Based Semantic File System for AIOS&lt;/em&gt;, 是 AIOS 在存储层上的一个语义文件系统设计. 它试图把传统“精确路径 + 明确命令”的文件操作方式, 扩展成“通过自然语言驱动文件读写、检索和管理”的交互模式.&lt;/p&gt;
&lt;p&gt;在当前仓库中, 与 LSFS 相关的核心实现主要位于 &lt;code&gt;aios/storage/filesystem/lsfs.py&lt;/code&gt;, 而对外暴露的存储 API 则在 &lt;code&gt;cerebrum/storage/apis.py&lt;/code&gt;. 从接口设计上看, 它支持 &lt;code&gt;mount&lt;/code&gt;、&lt;code&gt;retrieve_file&lt;/code&gt;、&lt;code&gt;create_file&lt;/code&gt;、&lt;code&gt;create_dir&lt;/code&gt;、&lt;code&gt;write_file&lt;/code&gt;、&lt;code&gt;rollback_file&lt;/code&gt; 和 &lt;code&gt;share_file&lt;/code&gt; 等操作, 体现的是 AIOS 把存储也抽象成 syscall 的思路.&lt;/p&gt;
&lt;p&gt;不过要注意, &lt;strong&gt;LSFS 并不是当前新闻项目的主业务链路&lt;/strong&gt;. 现在这版 AIOS-NP 的新闻系统, 更直接依赖的是 &lt;code&gt;runtime_support/artifacts.py&lt;/code&gt; 中的 &lt;code&gt;ArtifactStore&lt;/code&gt; 抽象来管理中间产物和最终结果.&lt;/p&gt;
&lt;h1&gt;内核窥探&lt;/h1&gt;
&lt;p&gt;AIOS实现了自己的 agent runtime 基础设施，而非简单的LLM API包装。通过上面对架构的了解，我们知道 cerebrum 侧有统一的 LLM/Tool/Storage/Memory 的 Query/Response 协议；aios 侧有统一的syscall分发器；有独立的 scheduler 层；有自己的 LLM core adapter、memory、storage、tool manager、context manager。&lt;/p&gt;
&lt;p&gt;现在，我们来详细看看这个基础设施是怎么运行起来的、有多大的扩展性、有哪些优势。&lt;/p&gt;
&lt;h2&gt;1. 运行&lt;/h2&gt;
&lt;h3&gt;(1) 启动、调度阶段&lt;/h3&gt;
&lt;p&gt;启动阶段有几个重要的事，由于内核本质上就是一个服务，所以最重要的统一请求入口在 &lt;code&gt;runtime/launch.py&lt;/code&gt; 中定义了，用于统一接收Query的运行时服务。&lt;/p&gt;
&lt;p&gt;首先，我们会启动FastAPI服务，将统一请求入口定为 &lt;code&gt;/query&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;然后，我们会按顺序初始化核心组件，用initialize_xxx 函数，依次对 config -&amp;gt; llms -&amp;gt; storage -&amp;gt; memory -&amp;gt; tool -&amp;gt; scheduler -&amp;gt; factory 进行初始化。这里可以理解成两段，首先是四大能力模块先装起来，然后是调度器和agent工厂。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;initialize_llm_cores()&lt;/code&gt; 用于准备LLM子系统，它具体做了三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;从配置里读取models&lt;/li&gt;
&lt;li&gt;取&lt;code&gt;log_mode&lt;/code&gt;（决定日志写在终端还是日志文件，scheduler 会用到这个机制）和&lt;code&gt;use_context_mananger&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;调&lt;code&gt;llm.py&lt;/code&gt;的&lt;code&gt;useCore(...)&lt;/code&gt;。这里的useCore(...)实际上是用来返回&lt;code&gt;adapter.py&lt;/code&gt;的LLMAdapter&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而LLMAdapter在初始化的时候又继续做了这些事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;根据 provider 配置 API key&lt;/li&gt;
&lt;li&gt;初始化每个模型后端&lt;/li&gt;
&lt;li&gt;如果开了 use_context_manager，创建 SimpleContextManager&lt;/li&gt;
&lt;li&gt;配置路由策略，比如 sequential / smart&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，这一步，做了模型注册、API key注入、上下文管理开关、路由策略初始化。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;initialize_storage_manager()&lt;/code&gt;做的事情比较直接，它用于把存储子系统挂起来，从配置里拿到&lt;code&gt;root_dir&lt;/code&gt;（如果不是绝对路径就转为项目内的绝对路径），然后调用 &lt;code&gt;storage.py&lt;/code&gt; 的&lt;code&gt;useStorageManager(...)&lt;/code&gt;，它返回的是 &lt;code&gt;storage.py&lt;/code&gt; 的StorageManager，而StorageManager在构造的时候又会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;创建根目录&lt;/li&gt;
&lt;li&gt;默认使用 &lt;code&gt;filesystem_type=&quot;lsfs&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;实际挂上 &lt;code&gt;LSFS(root_dir, use_vector_db)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，这一步用于将storage syscall最终落在LSFS这个文件管理系统上。LSFS 还会尝试连本地 Redis 存版本记录。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;initialize_memory_manager()&lt;/code&gt; 比较简单，他从配置中读取 &lt;code&gt;log_mode&lt;/code&gt;，然后调用 &lt;code&gt;memory.py&lt;/code&gt;中的&lt;code&gt;useMemoryManager(...)&lt;/code&gt;，返回 &lt;code&gt;manager.py&lt;/code&gt; 里的MemoryManager，而MemoryManager在调用的时候外部又包裹了一层BaseMemoryManager。self.memories是Python进程内的字典，它是运行期间内存态。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;initialize_tool_manager() &lt;/code&gt; 用来挂载工具调用子系统，它直接调用 &lt;code&gt;tool.py&lt;/code&gt;的&lt;code&gt;useToolManager()&lt;/code&gt;。另外，ToolManager构造函数里面有个关键动作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初始化tool conflict map和锁&lt;/li&gt;
&lt;li&gt;启动MCP server&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这一步，除了启动工具系统外，连MCP工具服务也一并启动了。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;initialize_scheduler()&lt;/code&gt; 是启动最关键的一步，它用于将前面四个能力模块结成一个真正会跑的调度系统。它做了如下事情：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;重新读取 llms 配置里的 use_context_manager&lt;/li&gt;
&lt;li&gt;如果开了 context manager，就选 RR scheduler&lt;/li&gt;
&lt;li&gt;否则选 FIFO scheduler&lt;/li&gt;
&lt;li&gt;把 llms / memory / storage / tool 全都传给 scheduler&lt;/li&gt;
&lt;li&gt;最后直接 scheduler.start()&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，这一步将内核从“组件已创建”变成“开始消费 syscall 队列”。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;initialize_agent_factory()&lt;/code&gt;是给系统补上的agent提交能力和异步执行能力。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;initialize_components()&lt;/code&gt;作为启动编排器，它从ConfigManager切出每一块配置，按顺序初始化组件之后，校验四大核心组件是否都成功，然后再起scheduler和factory，就相当于一个统一装机脚本。&lt;code&gt;initialize_components_safe()&lt;/code&gt;是让内核再初始化失败的情况下也可以降级启动，成功时记录&lt;code&gt;startup_state[&quot;initialized_at&quot;]&lt;/code&gt;，失败时记录&lt;code&gt;startup_state[&quot;initialization_error&quot;]&lt;/code&gt;，然后返回全None的组件表，这样即使没有初始化成功，仍可以通过/status或者/core/status把错误暴露出，不至于完全起不来思路。&lt;/p&gt;
&lt;p&gt;最后，来做一点启动阶段的总结。 initialize_xxx() 都在返回后续runtime的实例对象，LLMAdapter、StorageManager、MemoryManager、ToolManager。它们根据配置，创建已经带状态、带资源、能执行请求的活对象。然后，用关键的一步initialize_scheduler()，将四个子系统接入统一的syscall消费框架，它决定调度器、把四个能力都塞进去、绑定 syscall 队列的读取入口、启动四个处理线程。最终，scheduler 会知道去哪里取 syscall。至于initialize_components()，是把实例写进字典，检查是否缺失，然后再进行初始化后面的scheduler和factory；initialize_components_safe()用来做降级检验。&lt;/p&gt;
&lt;p&gt;在 AIOS 中，syscall 是对高层 Query 的内核化封装。它不仅携带请求参数，还携带状态、时间、响应和同步事件等运行时信息。scheduler.start() 并不负责创建队列，而是启动各类 syscall 队列的消费者线程；真正的请求分流发生在 SyscallExecutor 中，它根据 Query 类型将 syscall 放入对应的全局队列，再由 scheduler 交给不同的 manager/adapter 执行。&lt;/p&gt;
&lt;h3&gt;(2) 请求阶段&lt;/h3&gt;
&lt;p&gt;请求阶段中，上层SDK会先走 Cerebrum API，把请求包装成 Query。通过API，构造出LLMQuery、MemoryQuery、StorageQuery、ToolQuery，然后再发到/query接口。&lt;/p&gt;
&lt;p&gt;不过这里还有一个容易忽略的细节：请求正式进入 &lt;code&gt;handle_query()&lt;/code&gt; 之前，&lt;code&gt;runtime/launch.py&lt;/code&gt; 里的 &lt;code&gt;QueryRequest&lt;/code&gt; 就已经会根据 &lt;code&gt;query_type&lt;/code&gt; 对 &lt;code&gt;query_data&lt;/code&gt; 做一轮类型恢复。也就是说，传进来的原始 JSON 会先被 Pydantic 尝试转换成对应的 &lt;code&gt;LLMQuery / ToolQuery / StorageQuery / MemoryQuery&lt;/code&gt;，之后才交给 &lt;code&gt;/query&lt;/code&gt; 路由继续处理。&lt;/p&gt;
&lt;p&gt;然后，/query就会根据query_type重建Query对象，再统一走SyscallExecutor，先看Query类型，然后再根据细分类型做路由，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LLMQuery(action_type=&quot;chat&quot;) -&amp;gt; 走 LLM syscall&lt;/li&gt;
&lt;li&gt;LLMQuery(action_type=&quot;call_tool&quot;) -&amp;gt; 先走 LLM，再转 Tool syscall&lt;/li&gt;
&lt;li&gt;LLMQuery(action_type=&quot;operate_file&quot;) -&amp;gt; 走文件操作逻辑&lt;/li&gt;
&lt;li&gt;StorageQuery -&amp;gt; 走 storage syscall&lt;/li&gt;
&lt;li&gt;MemoryQuery -&amp;gt; 走 memory syscall&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里还要补一层理解：&lt;code&gt;handle_query()&lt;/code&gt; 虽然对四类请求都做了重建，但 LLM 路径其实比另外三类更复杂。对于 &lt;code&gt;LLMQuery&lt;/code&gt;，内核还会先检查 &lt;code&gt;selected_llms[&quot;llms&quot;]&lt;/code&gt; 这个全局选择的模型列表；如果请求里没有显式指定模型，就尝试补上当前选中的模型；如果显式指定了模型，还会校验这些模型是否真的已经被选中。只有这一步通过后，才会真正把 LLM 请求交给 &lt;code&gt;execute_request&lt;/code&gt;。而 &lt;code&gt;storage / tool / memory&lt;/code&gt; 三类路径则相对直接，基本是重建对象后就下发执行。&lt;/p&gt;
&lt;p&gt;再往下一层看，&lt;code&gt;execute_request()&lt;/code&gt; 也并不是简单地“一种 Query 对应一次 syscall”。有些 action_type / operation_type 会展开成一段多阶段链路。例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;call_tool&lt;/code&gt;：先走一次 LLM syscall，让大模型产出 &lt;code&gt;tool_calls&lt;/code&gt;，再组装成 &lt;code&gt;ToolQuery&lt;/code&gt; 继续下发&lt;/li&gt;
&lt;li&gt;&lt;code&gt;operate_file&lt;/code&gt;：先让 LLM 把自然语言文件意图解析成 storage tool calls，再转成 &lt;code&gt;StorageQuery&lt;/code&gt; 执行，最后还会再用一次 LLM 对操作结果做总结&lt;/li&gt;
&lt;li&gt;&lt;code&gt;add_agentic_memory&lt;/code&gt;：先分析内容、再检索相似记忆、再做 memory evolve，最后才真正写入或更新 memory&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，这一层最值得记住的是：&lt;strong&gt;AIOS 里的请求路由不是平面的 switch-case，而是允许在 syscall 之间继续展开出新的 syscall 链。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;SyscallExecutor会创建具体syscall 对象，并为其分配pid、source、status、timestamp，然后放入全局队列，等待syscall完成。也就是说，AIOS 不是“收到请求就直接调函数”，而是先把请求转成系统调用对象，再交给调度器消费。&lt;/p&gt;
&lt;p&gt;这里“等待syscall完成”也值得说清楚：原始请求线程并不是把请求扔进队列就结束了，而是会 &lt;code&gt;syscall.start()&lt;/code&gt; 之后再 &lt;code&gt;syscall.join()&lt;/code&gt;，等待 scheduler 真正执行完对应请求并把结果写回。所以从外部 HTTP 调用的视角看，&lt;code&gt;/query&lt;/code&gt; 最终仍然是一次完整返回；只是内部实现上，已经被拆成了“入队 -&amp;gt; 调度 -&amp;gt; 执行 -&amp;gt; 回填结果”的过程。&lt;/p&gt;
&lt;p&gt;之后，scheduler 消费队列，分别交给四大manager执行（LLMAdapter、StorageManager、MemoryManager、ToolManager）。&lt;/p&gt;
&lt;p&gt;最后，执行完成后，再顺着沿路返回。syscall的状态被改为done，response被写回syscall对象，/query返回给Cerebrum API，agent代码拿到结果后继续运行。&lt;/p&gt;
&lt;p&gt;流程：业务代码 -&amp;gt; Cerebrum API -&amp;gt; /query -&amp;gt; SyscallExecutor -&amp;gt; Queue -&amp;gt; Scheduler -&amp;gt; Manager -&amp;gt; Response -&amp;gt; 业务代码。&lt;/p&gt;
&lt;h2&gt;2. 四大核心组件如何工作&lt;/h2&gt;
&lt;p&gt;前面的“运行”解决的是横向总流程：请求怎样进入内核、怎样被调度、怎样返回结果。接下来更值得纵向看四大核心组件本身是如何工作的。这样再看 Memory 和 Storage，就不会只停留在“有这个 API”的层面。&lt;/p&gt;
&lt;h3&gt;(1) LLM：统一聊天、结构化输出与工具调用&lt;/h3&gt;
&lt;p&gt;LLM 这一条链的入口在 &lt;code&gt;cerebrum.llm.apis&lt;/code&gt;，对外统一表现为 &lt;code&gt;LLMQuery -&amp;gt; LLMResponse&lt;/code&gt;。上层常见调用包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm_chat&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;llm_chat_with_json_output&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;llm_chat_with_tool_call_output&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;llm_call_tool&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;llm_operate_file&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些 API 最终都会被包装成 &lt;code&gt;LLMQuery&lt;/code&gt; 发往 &lt;code&gt;/query&lt;/code&gt;。进入内核后，&lt;code&gt;execute_request()&lt;/code&gt; 会先看 &lt;code&gt;action_type&lt;/code&gt;，再决定：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普通聊天是否直接走 LLM syscall&lt;/li&gt;
&lt;li&gt;是否要先让模型产出 &lt;code&gt;tool_calls&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;是否要把自然语言文件请求继续展开成 storage syscall&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正和模型后端交互的是 &lt;code&gt;LLMAdapter&lt;/code&gt;。这一层负责：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;根据配置选择模型 provider&lt;/li&gt;
&lt;li&gt;处理 &lt;code&gt;message_return_type=&quot;json&quot;&lt;/code&gt; 之类的结构化输出约束&lt;/li&gt;
&lt;li&gt;在支持原生 function calling 的后端上传递 &lt;code&gt;tools + tool_choice=&quot;auto&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;在不支持原生 tool calling 的模型上，把工具说明合并进 prompt 再解析输出&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 LLM 组件的关键不是“简单调一次模型”，而是把不同形态的 LLM 能力统一收口到一个 runtime 组件里。&lt;/p&gt;
&lt;h3&gt;(2) Memory：运行期对象表 + 持久化向量检索&lt;/h3&gt;
&lt;p&gt;Memory 这一条链的入口在 &lt;code&gt;cerebrum.memory.apis&lt;/code&gt;，统一表现为 &lt;code&gt;MemoryQuery -&amp;gt; MemoryResponse&lt;/code&gt;。当前项目最常用的接口是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;create_memory&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;create_agentic_memory&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;search_memories&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些接口最终会变成 &lt;code&gt;operation_type&lt;/code&gt; 不同的 &lt;code&gt;MemoryQuery&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;add_memory&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;add_agentic_memory&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;retrieve_memory&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;进入内核之后，&lt;code&gt;execute_request()&lt;/code&gt; 会把它们交给 memory syscall，再由 scheduler 分发给 &lt;code&gt;MemoryManager&lt;/code&gt;，最后实际落到 &lt;code&gt;BaseMemoryManager&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;BaseMemoryManager&lt;/code&gt; 当前采用的是一种“运行期内存态 + 持久化向量索引”的混合实现：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;运行期对象表&lt;/strong&gt;&lt;br /&gt;
它会维护一个 &lt;code&gt;self.memories&lt;/code&gt; 字典，用来保存当前内核运行期间的 &lt;code&gt;MemoryNote&lt;/code&gt; 对象。这意味着在一次内核启动周期内，memory 是持续可用的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;持久化向量检索层&lt;/strong&gt;&lt;br /&gt;
同时它又会把 memory 内容写入 &lt;code&gt;ChromaRetriever&lt;/code&gt;。Chroma 使用持久化目录，因此 memory 具备跨进程保存向量索引的能力。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;add_memory&lt;/code&gt; / &lt;code&gt;add_agentic_memory&lt;/code&gt; 的写入流程是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把 &lt;code&gt;MemoryQuery&lt;/code&gt; 转成 &lt;code&gt;MemoryNote&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;content + metadata&lt;/code&gt; 写入 Chroma&lt;/li&gt;
&lt;li&gt;同时把 &lt;code&gt;MemoryNote&lt;/code&gt; 放进 &lt;code&gt;self.memories&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;search_memories&lt;/code&gt; 的检索流程则是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先让 &lt;code&gt;ChromaRetriever&lt;/code&gt; 根据 query 做向量相似搜索&lt;/li&gt;
&lt;li&gt;拿回相近文档的 &lt;code&gt;doc_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;再用这些 &lt;code&gt;doc_id&lt;/code&gt; 回头查 &lt;code&gt;self.memories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;最后组装成 &lt;code&gt;MemoryResponse(search_results=...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，当前 AIOS memory 不是“纯 Python 字典”，也不是“纯数据库”。更准确地说，它是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;以 &lt;code&gt;self.memories&lt;/code&gt; 保存运行期完整对象，以 Chroma 保存可持续的语义检索索引。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这也解释了它的一个现实特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单次启动内，memory 连续性很好&lt;/li&gt;
&lt;li&gt;跨重启时，向量索引是持续的&lt;/li&gt;
&lt;li&gt;但完整对象表 &lt;code&gt;self.memories&lt;/code&gt; 没有做彻底的重建回填，因此它更像“半持久化记忆系统”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AIOS 为了让 memory 子系统在受限环境里也能工作，给 Chroma 配了一个本地 embedding_function。LocalHashEmbeddingFunction 是采用了一种经典的「哈希嵌入」（feature hashing，又称 hashing‑trick）的变体，它分为以下几步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;特征提取：第一步先从文本中抽取两种类型的特征，第一类是分词/正则表达式，利用正则表达式提取英文单词、数字、中文等作为token的特征；然后去掉空白符，将字符串压紧，滑动窗口生成2-gram和3-gram。&lt;/li&gt;
&lt;li&gt;有符号特征哈希：首先，我们对每个特征计算blake2b哈希，去输出的前4字节转成32位整数并对维度m（代码中是256）取模，得到该特征落入的“桶”的位置；接着，我们取哈希的第五字节，判断该字节的奇偶性来决定符号+1或者-1。（跟传统哈希相比，能消除内积的偏差）；最后，进行向量累积，初始化一个长度为m的零向量，对每个特征根据桶位置选择对应维度，将该维度加上sign(feature)，这样如果一个桶接收了多个特征会正负抵消或累加。&lt;/li&gt;
&lt;li&gt;归一化：哈希累加后的向量各维度是整数。有些特征较长文本会产生更多的哈希次数，因此向量的长度（L2 范数）会较大。为了用余弦相似度或最大内积搜索来比较文本，需要消除不同文本长度的影响。代码最后计算向量的 L2 范数并除以该范数得到单位向量。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种方法无需词表且支持动态语料（无状态），适合处理海量或流式数据，且内存占用小、中文友好。&lt;/p&gt;
&lt;p&gt;这套设计对当前新闻项目已经足够，因为项目主要依赖的是“相似编辑决策能否被检索出来”，而不是把 memory 当作一套完全独立的业务数据库。&lt;/p&gt;
&lt;p&gt;“新闻生成”不是一次纯文本补全，而是一条多阶段工作流。
它既需要记住“这次运行里刚发生了什么”，也需要参考“过去类似题材是怎么处理的”。不过本次项目中，大多数“本次工作流上下文”是通过intermediate/*.txt/json、pipeline.run()的阶段结果、event回调、当前进程对象状态决定的。&lt;/p&gt;
&lt;h3&gt;(3) Storage：文件系统能力通过 LSFS 收口&lt;/h3&gt;
&lt;p&gt;Storage 这一条链的入口在 &lt;code&gt;cerebrum.storage.apis&lt;/code&gt;，统一表现为 &lt;code&gt;StorageQuery -&amp;gt; StorageResponse&lt;/code&gt;。常见接口包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mount&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;create_dir&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;create_file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;write_file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;read_file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;list_dir&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete_file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete_dir&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些请求进入内核后，会交给 &lt;code&gt;StorageManager&lt;/code&gt;。当前仓库里 &lt;code&gt;StorageManager&lt;/code&gt; 默认挂接的底层实现是 &lt;code&gt;LSFS&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;也就是说，storage 这一层最终负责的是真实文件系统动作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;建目录&lt;/li&gt;
&lt;li&gt;读写文件&lt;/li&gt;
&lt;li&gt;枚举目录&lt;/li&gt;
&lt;li&gt;回滚文件版本&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果开启 &lt;code&gt;use_vector_db&lt;/code&gt;，LSFS 还会为文件内容维护向量索引；此外它也会尝试连接 Redis 保存版本记录。因此 storage 这一层本质上是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;文件正文 + 语义检索 + 版本管理&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;不过在当前新闻项目里，storage 的主使用方式不是“让业务代码直接到处调 &lt;code&gt;StorageQuery&lt;/code&gt;”，而是通过 &lt;code&gt;ArtifactStore&lt;/code&gt; 进一步包装后再用。&lt;/p&gt;
&lt;p&gt;我们需要storage层判断过去类似题材出现过没有，当时是通过还是拒绝，从而给现在的题材加减分，使用gate机制。&lt;/p&gt;
&lt;h3&gt;(4) Tool：工具资产与 runtime 调度链的结合点&lt;/h3&gt;
&lt;p&gt;Tool 这一条链的入口在 &lt;code&gt;cerebrum.tool.apis&lt;/code&gt;，统一表现为 &lt;code&gt;ToolQuery -&amp;gt; ToolResponse&lt;/code&gt;。最典型的接口是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;call_tool&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它和 &lt;code&gt;llm_call_tool&lt;/code&gt; 的区别在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;call_tool(...)&lt;/code&gt; 是显式调用工具&lt;/li&gt;
&lt;li&gt;&lt;code&gt;llm_call_tool(...)&lt;/code&gt; 是先让 LLM 决定工具，再执行工具&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当前新闻项目已经将 &lt;code&gt;hot_api&lt;/code&gt; 和 &lt;code&gt;web_search&lt;/code&gt; 这两类叶子能力下沉为本地工具，因此这条链在项目中已经真正用起来了。&lt;/p&gt;
&lt;p&gt;tool 请求进入内核后，会被分发到 &lt;code&gt;ToolManager&lt;/code&gt;。&lt;code&gt;ToolManager&lt;/code&gt; 的职责主要是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;接收 &lt;code&gt;tool_calls&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;加载工具实例&lt;/li&gt;
&lt;li&gt;执行 &lt;code&gt;tool.run(...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;返回结构化结果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而工具本体并不直接写在 &lt;code&gt;ToolManager&lt;/code&gt; 里，而是通过：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cerebrum.tool.core&lt;/code&gt; 本地工具目录&lt;/li&gt;
&lt;li&gt;&lt;code&gt;registry.py&lt;/code&gt; 本地注册表&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AutoTool.from_preloaded(...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一条链来加载。也就是说，Tool 组件真正解决的是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;如何把“工具资产”接入 runtime 调度系统，而不是停留在普通 Python 类调用。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这也是当前新闻项目近几轮重构里很重要的一点：业务编排仍由 &lt;code&gt;NewsWorkflowApp&lt;/code&gt; 控制，但 &lt;code&gt;hot_api&lt;/code&gt; 和 &lt;code&gt;web_search&lt;/code&gt; 这类叶子能力已经开始真正走 AIOS 原生 Tool runtime 路径。&lt;/p&gt;
&lt;h2&gt;3. 扩展性&lt;/h2&gt;
&lt;h3&gt;(1) 协议层拓展&lt;/h3&gt;
&lt;p&gt;首先，LLMQuery就预留了多种action_type，目前支持的chat、chat_with_json_output、chat_with_tool_call_output、call_tool、operate_file。这个action_type到底有什么作用呢？我们就拿chat_with_json_output为例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def llm_chat_with_json_output(
        agent_name: str, 
        messages: List[Dict[str, Any]], 
        base_url: str = aios_kernel_url,
        llms: List[Dict[str, Any]] = None,
        response_format: Dict[str, Dict] = None,
        require_kernel: bool | None = None,
    ) -&amp;gt; LLMResponse:

    query = LLMQuery(
        llms=llms,
        messages=messages,
        message_return_type=&quot;json&quot;,
        action_type=&quot;chat_with_json_output&quot;,
        response_format=response_format
    )
    try:
        return send_request(agent_name, query, base_url)
    except requests.RequestException:
        if _kernel_required(require_kernel):
            raise
        return _direct_openai_chat(
            agent_name=agent_name,
            messages=messages,
            llms=llms,
            response_format=response_format,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为了方便看我删掉了长长的docstring。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;message_return_type = &quot;json&quot;&lt;/code&gt; ，当LLMAdapter 真正发送请求的时候，会看这个字段，然后给模型的请求带上&lt;code&gt;response_format = {&quot;type&quot;:&quot;json_object&quot;}&lt;/code&gt;，让模型按照JSON对象返回。（这个response_format是OpenAI、LiteLLM这一类后端支持的参数，但是如果是本地HF模型，AIOS会退回成把schema指令拼进prompt。见llm_core的adapter和utils）。如果传入了更为具体的schema，它会覆盖上面的普通json_object，要求你返回符合schema的JSON。&lt;/p&gt;
&lt;p&gt;只需要换输出输出约束的时候，我们可以只换message_return_type、response_format等，但是如果想要一个新的执行语义，我们就可以定义新的action_type（比如chat_with_rerank、chat_with_citation_check、vision_align_then_chat之类的），最少需要改以下几层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LLMQuery的枚举&lt;/li&gt;
&lt;li&gt;加一个SDK封装函数（仿照llm_chat_with_json_output()）&lt;/li&gt;
&lt;li&gt;改内核分发：再syscall.py中，execute_request(...)加一个分支，比如：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;elif query.action_type == &quot;chat_with_rerank&quot;:
    # 先做一次预处理
    # 再调用 execute_llm_syscall
    return ...
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;如果需要新字段，要改/query重建逻辑，确保新字段不会在重建LLMQuery时候丢掉&lt;/li&gt;
&lt;li&gt;如果需要底层模型的特殊支持，还需要改llm_core的LLMAdapter。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个链路其实是这样的：handle_query -&amp;gt; execute_request -&amp;gt; execute_llm_syscall / execute_memory_syscall / ... -&amp;gt; _execute_syscall -&amp;gt; Queue -&amp;gt; Scheduler -&amp;gt; Manager。&lt;/p&gt;
&lt;h3&gt;(2) 执行模块拓展&lt;/h3&gt;
&lt;p&gt;LLMAdapter 初始化时会根据配置装载不同模型后端，并选择 routing strategy，这意味着：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型配置可换&lt;/li&gt;
&lt;li&gt;provider可换&lt;/li&gt;
&lt;li&gt;路由策略可换&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;StorageManager后端有抽象口，但是仓库主要还是LSFS。&lt;/p&gt;
&lt;p&gt;ToolManager 最后通过AutoTool.from_preloader(...)去拿工具实例，所以理论上可以拿到&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地工具&lt;/li&gt;
&lt;li&gt;ToolHub工具&lt;/li&gt;
&lt;li&gt;MCP工具&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AutoTool.from_preloaded 按照 cerebrum.manager.tool.ToolManager.load_tool 中 local = False/True，启动不同的服务。远程工具会在hub下载放进cache目录然后动态import；本地工具会按照注册表 &lt;code&gt;registry.py&lt;/code&gt; 加载。&lt;/p&gt;
&lt;h3&gt;(3) 调度层拓展性&lt;/h3&gt;
&lt;p&gt;scheduler 有一个明确的抽象基类，也就是说AIOS 把“请求如何排队和消费”单独抽成 scheduler，因此调度策略本身可以替换，而不需要改动 LLM / Memory / Storage / Tool 的具体实现。&lt;/p&gt;
&lt;p&gt;前面已经说过，scheduler 不是直接处理Query，而是处理已经入队的Syscall。调度器抽象基类已经通过 &lt;code&gt;process_llm_requests&lt;/code&gt;、&lt;code&gt;process_memory_requests&lt;/code&gt;、&lt;code&gt;process_storage_requests&lt;/code&gt;和&lt;code&gt;process_tool_requests&lt;/code&gt;固定住了，我们只要任何scheduler实现这几个方法就能替换。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;code&gt;fifo_scheduler.py&lt;/code&gt; 中，每类请求各自一个线程，LLM 队列按时间窗口 batch，memory/storage/tool 基本是谁先入队谁先处理&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;rr_scheduler.py&lt;/code&gt; 中，同样也是四类队列，但是会给syscall设置time_slice，配合context_manager处理中断、续跑。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(4) 应用层拓展性&lt;/h3&gt;
&lt;p&gt;可以挂在新的应用层，构建新的APP (Agent Application)。&lt;/p&gt;
&lt;h2&gt;4. 优势与代价&lt;/h2&gt;
&lt;p&gt;我们新说说优势，AIOS把 llm / memory / storage / tool 都抽成同级能力，然后统一走：Query -&amp;gt; Syscall -&amp;gt; Queue -&amp;gt; Scheduler -&amp;gt; Manager，也就是说，不同类型资源实际上是走同一套处理范式。&lt;/p&gt;
&lt;p&gt;然后，调度和执行也被解耦了。scheduler不直接实现业务，只负责消费队列和调度syscall。这样可以直接更换底层调度算法、LLM backend、storage backend之类的，不用动业务层代码。&lt;/p&gt;
&lt;p&gt;此外，AIOS的核心抽象是syscall，天然将大模型调用、文件操作、记忆检索、工具执行看作同等地位的系统资源，更像agent OS。&lt;/p&gt;
&lt;p&gt;对比LangGraph而言，LangGraph 和 AIOS 都不只是 prompt wrapper，都有自己的 runtime 设计；但 LangGraph 的 runtime 更偏向 graph/state/checkpoint 的应用编排体系，而 AIOS 的 runtime 更偏向 syscall/scheduler/manager 的内核式能力调度体系。前者更适合直接构建业务工作流，后者更适合作为统一 agent 能力底座。&lt;/p&gt;
&lt;h2&gt;5. 总结&lt;/h2&gt;
&lt;p&gt;现在，我们可以对内核总结一张总流程图：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[业务代码 / Agent / App] --&amp;gt; B[Cerebrum API]
    B --&amp;gt; C[构造 Query&amp;lt;br/&amp;gt;LLMQuery / MemoryQuery / StorageQuery / ToolQuery]
    C --&amp;gt; D[send_request]
    D --&amp;gt; E[&quot;/query (FastAPI)&quot;]

    subgraph KernelStartup[启动阶段]
        K1[读取 ConfigManager 配置]
        K2[initialize_llm_cores&amp;lt;br/&amp;gt;LLMAdapter]
        K3[initialize_storage_manager&amp;lt;br/&amp;gt;StorageManager -&amp;gt; LSFS]
        K4[initialize_memory_manager&amp;lt;br/&amp;gt;MemoryManager -&amp;gt; BaseMemoryManager]
        K5[initialize_tool_manager&amp;lt;br/&amp;gt;ToolManager]
        K6[initialize_scheduler&amp;lt;br/&amp;gt;FIFO / RR]
        K7[initialize_agent_factory]
        K1 --&amp;gt; K2 --&amp;gt; K3 --&amp;gt; K4 --&amp;gt; K5 --&amp;gt; K6 --&amp;gt; K7
    end

    E --&amp;gt; F[QueryRequest 类型恢复]
    F --&amp;gt; G[handle_query]
    G --&amp;gt; H[重建 Query&amp;lt;br/&amp;gt;补 llms / 保留字段]
    H --&amp;gt; I[SyscallExecutor.execute_request]

    I --&amp;gt; J{Query 类型 / action_type}
    J --&amp;gt; J1[LLMQuery]
    J --&amp;gt; J2[MemoryQuery]
    J --&amp;gt; J3[StorageQuery]
    J --&amp;gt; J4[ToolQuery]

    J1 --&amp;gt; L1[_execute_syscall]
    J2 --&amp;gt; L2[_execute_syscall]
    J3 --&amp;gt; L3[_execute_syscall]
    J4 --&amp;gt; L4[_execute_syscall]

    L1 --&amp;gt; Q1[LLM Queue]
    L2 --&amp;gt; Q2[Memory Queue]
    L3 --&amp;gt; Q3[Storage Queue]
    L4 --&amp;gt; Q4[Tool Queue]

    subgraph Scheduler[调度阶段]
        S1[FIFO / RR Scheduler]
        S1 --&amp;gt; P1[process_llm_requests]
        S1 --&amp;gt; P2[process_memory_requests]
        S1 --&amp;gt; P3[process_storage_requests]
        S1 --&amp;gt; P4[process_tool_requests]
    end

    Q1 --&amp;gt; P1
    Q2 --&amp;gt; P2
    Q3 --&amp;gt; P3
    Q4 --&amp;gt; P4

    P1 --&amp;gt; M1[LLMAdapter]
    P2 --&amp;gt; M2[MemoryManager]
    P3 --&amp;gt; M3[StorageManager / LSFS]
    P4 --&amp;gt; M4[ToolManager]

    M1 --&amp;gt; R[Response 回填到 Syscall]
    M2 --&amp;gt; R
    M3 --&amp;gt; R
    M4 --&amp;gt; R

    R --&amp;gt; T[syscall done / event set]
    T --&amp;gt; U[&quot;/query 返回结果&quot;]
    U --&amp;gt; V[Cerebrum API 收到响应]
    V --&amp;gt; W[业务代码继续执行]

    J1 -. 特殊分支 .-&amp;gt; X1[call_tool&amp;lt;br/&amp;gt;LLM -&amp;gt; ToolQuery -&amp;gt; Tool]
    J1 -. 特殊分支 .-&amp;gt; X2[operate_file&amp;lt;br/&amp;gt;LLM解析 -&amp;gt; StorageQuery]
    J2 -. 特殊分支 .-&amp;gt; X3[add_agentic_memory&amp;lt;br/&amp;gt;analyze -&amp;gt; retrieve -&amp;gt; evolve -&amp;gt; add/update]

&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;业务层&lt;/h1&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;比赛的时候，我是通过AutoGen hook直接在AIOS上挂载AutoGen框架，主线是让AutoGen的agent通过AIOS发送请求。这当然可以，因为AIOS做了相应的adapter，见cerebrum层的community/adapter/autogen_adapter.py。&lt;/p&gt;
&lt;p&gt;比赛的要求是“设计一个由不少于4种智能体协作完成的复杂任务，该任务可分解为至少4个并行子任务并能自动分配。系统应实现包含至少3对智能体双向交互的协作流程，同时满足子任务间的依赖关系和执行顺序约束。”，我们想到的最直观方案就是一份综合新闻报的制作，因为它可以安排不同主题的Agent并行运行，反思、审阅，最终合并生成新闻报。&lt;/p&gt;
&lt;p&gt;比赛之后，重新对Agent进行编排，不在走AutoGen，将其变成了显式的业务工作流编排器。现在的业务层已经不是基于 AutoGen 对话框架组织 agent，而是以 NewsWorkflowApp 为核心的显式工作流系统。它把新闻日报生产拆成固定阶段，由应用层统一编排，再调用各类 agent 完成具体任务：run_news_app.py -&amp;gt; cli.py -&amp;gt; config.py -&amp;gt; NewsWorkflowApp -&amp;gt; stages -&amp;gt; agents -&amp;gt; output/service。&lt;/p&gt;
&lt;h2&gt;2. 入口层&lt;/h2&gt;
&lt;p&gt;入口层分为三层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;壳层入口：接受启动命令&lt;/li&gt;
&lt;li&gt;CLI适配层：解析参数，实例化应用&lt;/li&gt;
&lt;li&gt;配置收敛层：将JSON配置转为稳定配置对象&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;入口层的核心职责不是执行业务，而是把“启动参数”和“原始配置文件”整理成一个可运行的应用对象。run_news_app.py 只是最外层壳入口，cli.py 负责命令行参数适配，config.py 负责将 config.json 转为类型化的 NewsAppConfig，最终在 NewsWorkflowApp.&lt;strong&gt;init&lt;/strong&gt; 中完成环境变量、配置、运行目录、artifact store 与 workflow memory 的初始化。这样，真正的新闻业务流程可以从一个上下文完整的应用对象开始执行，而不是散落在多个脚本之中。&lt;/p&gt;
&lt;h2&gt;3. 编排层&lt;/h2&gt;
&lt;p&gt;编排层的核心是 &lt;code&gt;pipeline.py&lt;/code&gt; 的NewsWorkflowApp。NewsWorkflowApp 是新闻业务的总编排器，它不直接承担每一步业务细节，而是负责组织阶段、调度 agent、管理中间产物、发出运行事件，并最终收口成一份日报。&lt;/p&gt;
&lt;p&gt;首先，&lt;code&gt;__init__ &lt;/code&gt;负责装配一个可运行的业务应用，它分为七步：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;校验模式，mode只能是parallel或serial&lt;/li&gt;
&lt;li&gt;加载环境变量，load_project_env()（它在runtime_support.env）&lt;/li&gt;
&lt;li&gt;加载业务配置，load_news_app_config(config_path)，这里定义了重试次数、各类别新闻个数等等业务超参数&lt;/li&gt;
&lt;li&gt;读取API Key&lt;/li&gt;
&lt;li&gt;确定工作流阶段顺序，self.workflow_stage_order&lt;/li&gt;
&lt;li&gt;初始化运行支撑组件&lt;/li&gt;
&lt;li&gt;确保目录存在&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;resume_from_existing
说明这条流水线支持“从中间继续跑”，不是只能从头开始。_components
这是后面懒加载 agent 的缓存池，说明 agent 实例不是提前全建好，而是按需创建。&lt;/p&gt;
&lt;p&gt;然后，我们会进入 &lt;code&gt;run()&lt;/code&gt; 总控循环。它的结构非常清晰：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;发出启动事件，记录 mode、config_path、stages、resume_from_existing&lt;/li&gt;
&lt;li&gt;准备输入产物，如果是恢复执行就保留上游产物并清理下游产物，否则清空intermediate临时文件夹。&lt;/li&gt;
&lt;li&gt;按顺序循环执行，遍历self.workflow_stage_order，每个阶段都发stage_started，调_run_stage(stage_name)，记录耗时，完成后发stage_finished。&lt;/li&gt;
&lt;li&gt;失败即终止，为了不推送半成品，一旦某阶段结果不是success，就抛出异常结束整个workflow。&lt;/li&gt;
&lt;li&gt;成功统一收口，汇总总耗时、阶段耗时、阶段结果，生成results，发送run_finished。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;简单来说，NewsWorkflowApp.run() 采用的是典型的**工作流编排器（workflow orchestrator）**写法：通过一个总控循环按阶段顺序驱动任务执行；阶段分发通过 dispatch table 完成；运行过程中的进度、耗时和结果则通过 event callback / observer hook 形式向外发射，供外层的 ecosystem 和 service 记录状态与构建在线观测能力。&lt;/p&gt;
&lt;h2&gt;4. 规则与桥接层&lt;/h2&gt;
&lt;p&gt;有了规则和桥接层，我们将一条会跑的agent流水线，变成了一条有编辑判断、底座接入策略、失败兜底的在线新闻系统。&lt;/p&gt;
&lt;h3&gt;(1) 规则层&lt;/h3&gt;
&lt;p&gt;首先，规则层 editorial.py，是新闻业务的编辑规则中心。这个函数将搜索文本解析成SearchSource，包含标题、链接、内容、域名、是否可信新闻源、是否低信号来源。&lt;/p&gt;
&lt;p&gt;然后，evaluate_generation_input()会决定值不值得生成，也就是先gate，再写稿，它会根据以下信息给题材打分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;搜索结果是不是空的&lt;/li&gt;
&lt;li&gt;有没有解析出有效信源&lt;/li&gt;
&lt;li&gt;有没有可信新闻站点&lt;/li&gt;
&lt;li&gt;是否大多是低信号来源&lt;/li&gt;
&lt;li&gt;标题像不像词条/历史纪念/成语解释&lt;/li&gt;
&lt;li&gt;内容像不像虚构剧情&lt;/li&gt;
&lt;li&gt;memory 里有没有相似题材的历史通过/拒绝记录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;接着，evaluate_publishability()决定要不要进日报。这是判断生成完了能不能发送，它主要看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;最终展示用的 sources 是否存在&lt;/li&gt;
&lt;li&gt;最高信源匹配度够不够&lt;/li&gt;
&lt;li&gt;平均匹配度够不够&lt;/li&gt;
&lt;li&gt;是否没有可信来源、反而低信号很多&lt;/li&gt;
&lt;li&gt;标题是否呈现虚构剧情特征&lt;/li&gt;
&lt;li&gt;memory 对类似题材有没有历史反馈&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最终，会有一个filter_display_sources()，这是对前端/日报展示服务进行过滤的，它：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;去掉重复链接&lt;/li&gt;
&lt;li&gt;去掉低相关来源&lt;/li&gt;
&lt;li&gt;只保留足够高分的来源&lt;/li&gt;
&lt;li&gt;最多保留有限条数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;除此之外，route_story_category()允许重新分类； build_story_dedupe_key()做文本归一化、提取 lead、再结合事件关键词族生成 key 来去重；summarize_editorial_memory_feedback() 拿当前 topic 去匹配历史记忆，计算相似度，统计历史通过/拒绝数量，给当前决策加减分，产出理由。&lt;/p&gt;
&lt;p&gt;规则层的核心不是“让 LLM 替我判断”，而是“把新闻性、可信度、重分类、去重这些编辑判断显式写成可解释规则”。&lt;/p&gt;
&lt;h3&gt;(2) 桥接层&lt;/h3&gt;
&lt;p&gt;桥接层决定业务层如何接入 AIOS / Cerebrum，但又不把业务代码直接耦死在底层 API 上。&lt;/p&gt;
&lt;p&gt;它的核心思想不是“再实现一遍底层能力”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把通用的 kernel / SDK 能力重新包装成业务层容易调用的组件&lt;/li&gt;
&lt;li&gt;把底层可能失败、超时、切换后端的复杂性收在中间层&lt;/li&gt;
&lt;li&gt;让上层 workflow 只关心“我要不要存工件、要不要查记忆、要不要调工具”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在当前项目里，这层最典型的两个代表就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ArtifactStore&lt;/code&gt;：负责统一管理中间产物与最终日报文件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WorkflowMemoryRecorder&lt;/code&gt;：负责把通用 memory API 包装成“编辑决策记忆”组件&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，桥接层更像一个&lt;strong&gt;适配与收口层&lt;/strong&gt;：&lt;br /&gt;
它把 AIOS 底座能力转成新闻业务真正能稳定使用的接口。至于 Tool、Memory、Storage 分别是怎么接入的，下面放到 &lt;code&gt;AIOS能力接入层&lt;/code&gt; 单独展开，这样层次会更清楚，也避免和后文重复。&lt;/p&gt;
&lt;h2&gt;5. AIOS能力接入层&lt;/h2&gt;
&lt;h3&gt;(1) Tool 系统接入&lt;/h3&gt;
&lt;p&gt;业务编排仍由 NewsWorkflowApp 显式控制，但底层叶子能力开始逐步下沉为 AIOS 原生 tool 调用；同时保留 fallback。现在，我们将 hot_api 和 web_search 工具都注册为了 AIOS 内核可以直接使用的本地工具，当前主路径通过显式 &lt;code&gt;call_tool(...)&lt;/code&gt; 进入 Tool runtime；只有在更开放的 agentic 场景下，才需要 &lt;code&gt;llm_call_tool(...)&lt;/code&gt; 这类“由 LLM 决定工具调用”的原语。&lt;/p&gt;
&lt;p&gt;我们可以简单看看关于tool的api。首先，llm_call_tool(...) 它并不是严格意义上的 ReAct。它本质上是一次“LLM 生成 tool_calls + runtime 执行工具”的单步 tool-calling 流程：在支持原生 function calling 的后端上，它通过 tools + tool_choice=&quot;auto&quot; 让模型直接返回工具调用；在不支持原生 tool calling 的本地模型上，则通过 prompt 注入工具描述并把输出解析为 JSON 形式的 tool_calls。但它没有内建将工具结果再次回喂给模型的多轮 reasoning loop，因此更像 ReAct 的一个基础原语，而不是完整的 ReAct agent。llm_chat_with_tool_call_output(...)则是只决策不执行。&lt;/p&gt;
&lt;p&gt;在本次新闻流水线中，因为tool都是固定随流水线调用，所以我们只会使用call_tool(...)。&lt;/p&gt;
&lt;h3&gt;(2) Memory 系统接入&lt;/h3&gt;
&lt;p&gt;Agent当中记忆系统非常关键，我们来深入看看AIOS是如何处理记忆层。&lt;/p&gt;
&lt;p&gt;当前项目并没有让各个 agent 直接零散调用 AIOS memory API，而是先通过 memory.py (line 19) 中的 WorkflowMemoryRecorder 做了一层业务化封装。这样，业务层拿到的不是通用的 create_memory / search_memories，而是一个能够直接服务新闻工作流的记忆组件。WorkflowMemoryRecorder 的底层仍然调用 Cerebrum 的 memory API，包括create_memory、create_agentic_memory、search_memories等。&lt;/p&gt;
&lt;p&gt;这些api对memory进行操控，让生成新闻具有弱先验经验而不是从头再来，从而保持可控性。引入 memory 的初衷是把过去的编辑经验，作为当前 gate 的一个参考项。&lt;/p&gt;
&lt;p&gt;当前 AIOS-NP 中的 editorial memory，不是“让过去决定替代当前判断”，而是“把过去类似题材的处理经验，作为一个有限权重的编辑先验，去辅助当前基于搜索证据的新闻性判断”。&lt;/p&gt;
&lt;p&gt;不过，为了避免自我强化偏见，我们让拒绝侧最多减18，通过侧最多加12，只是可控幅度的倾向修正项，真正决定的还是当前的证据。我们还给记忆系统加入了TTL，如果命中且过期，就不返回且删除。&lt;/p&gt;
&lt;h3&gt;(3) Storage / ArtifactStore 接入&lt;/h3&gt;
&lt;p&gt;Storage 和 Memory 在 AIOS 中是两套并列能力。Memory 更关注“过去见过什么、相似题材如何处理”，而 Storage 更关注“当前工作流产生的中间文件和最终产物如何保存、读取、枚举和删除”。因此，在新闻项目里，Storage 并不是“记忆的附属品”，而是负责承载整条流水线工件的独立系统。&lt;/p&gt;
&lt;p&gt;当前项目并没有让业务代码直接零散调用 &lt;code&gt;cerebrum.storage.apis&lt;/code&gt;。相反，它先在 &lt;code&gt;runtime_support/artifacts.py&lt;/code&gt; 中抽象出统一的 &lt;code&gt;ArtifactStore&lt;/code&gt; 接口，再由业务层统一依赖这层抽象。&lt;code&gt;ArtifactStore&lt;/code&gt; 这一层暴露的不是底层 syscall，而是更贴近工作流的工件操作，包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;write_text / write_json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;read_text / read_json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;exists&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;glob / glob_in&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete_file / delete_dir&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;describe&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步很重要，因为它意味着业务层拿到的不是一堆零散的 storage API，而是一个“专门用来管理新闻工作流工件”的组件。&lt;/p&gt;
&lt;p&gt;在具体实现上，项目提供了两套后端：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;LocalArtifactStore&lt;/code&gt;&lt;br /&gt;
它是最简单、最稳定的本地实现。所有读写都直接落到本地文件系统中，适合作为默认模式，也适合作为 AIOS storage 不可用时的回退路径。对于新闻项目来说，这保证了即使底座暂时异常，日报流水线仍然可以在本机完整跑通。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;AIOSStorageArtifactStore&lt;/code&gt;&lt;br /&gt;
它负责把 ArtifactStore 桥接到 AIOS storage 能力。这个类会优先尝试通过 Cerebrum 的 storage API 与内核交互，典型调用包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mount&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;create_dir&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;write_file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;read_file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;list_dir&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete_file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete_dir&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;也就是说，Storage 的真实调用链会变成：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ArtifactStore -&amp;gt; cerebrum.storage.apis -&amp;gt; StorageQuery -&amp;gt; /query -&amp;gt; StorageManager -&amp;gt; LSFS&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这里值得注意的一点是：&lt;code&gt;AIOSStorageArtifactStore&lt;/code&gt; 不是“纯粹依赖内核，失败就崩”，而是内置了 &lt;code&gt;local_fallback&lt;/code&gt;。只要内核 storage 调用失败，它就会自动退回本地文件系统。这种设计非常适合当前新闻系统，因为它需要的是“在线可运行”，而不是为了展示 AIOS 而牺牲稳定性。&lt;/p&gt;
&lt;p&gt;ArtifactStore 的运行时选择由 &lt;code&gt;build_artifact_store()&lt;/code&gt; 和 &lt;code&gt;get_artifact_store()&lt;/code&gt; 完成。当前项目会根据环境变量决定到底是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用本地后端&lt;/li&gt;
&lt;li&gt;还是优先走 AIOS storage 后端&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;同时，&lt;code&gt;get_artifact_store()&lt;/code&gt; 还会缓存默认实例，避免业务层反复重新创建存储对象。也就是说，对上层业务代码来说，Storage 后端是可切换的，但调用方式保持一致。&lt;/p&gt;
&lt;p&gt;在新闻项目里，ArtifactStore 贯穿了几乎整条工作流：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pipeline.py&lt;/code&gt; 会用它保存 gate 结果、修正后的中间产物，并在运行开始时清理 &lt;code&gt;intermediate&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HotApiAgent&lt;/code&gt; 用它写入 &lt;code&gt;hot_api.txt/json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SortAgent&lt;/code&gt; 用它写入各栏目分类文件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WebSearchAgent&lt;/code&gt; 用它写入 &lt;code&gt;*_search.txt&lt;/code&gt;、&lt;code&gt;*_image.txt&lt;/code&gt; 和搜索元数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;JudgeAgent&lt;/code&gt;、&lt;code&gt;MakerAgent&lt;/code&gt; 用它写入新闻正文、sources、最终 TXT / JSON / HTML 日报&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ecosystem.py&lt;/code&gt; 也通过同一套存储接口保存 run、state、metrics、snapshot 等在线运行记录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 Storage 在这里并不是“随手落盘”这么简单，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;整条新闻流水线的工件基础设施。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;你也可以把它和 Memory 做一个非常清楚的对照：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Memory&lt;/code&gt;：保存“编辑经验”和“相似题材历史决策”&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Storage&lt;/code&gt;：保存“这次工作流真正产出的文件工件”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当前这版 AIOS-NP 的 Storage 接入方式，本质上是一种非常工程化的折中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;业务层通过 &lt;code&gt;ArtifactStore&lt;/code&gt; 保持接口稳定&lt;/li&gt;
&lt;li&gt;能用 AIOS storage 时就尽量接入底座&lt;/li&gt;
&lt;li&gt;AIOS storage 有问题时立即 fallback 到本地&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一来，项目既保留了 AIOS 架构的接入路径，又不会因为底层存储链不稳定而让整套在线日报系统失去可用性。&lt;/p&gt;
&lt;h2&gt;6. 能力层&lt;/h2&gt;
&lt;p&gt;前面的编排层决定“阶段怎么走”，规则层决定“什么值得写、什么值得发”，AIOS能力接入层决定“底层 Tool / Memory / Storage 怎么接进来”。到了能力层，问题就变成了：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;每一个具体的业务动作，到底由谁来完成。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;因此，能力层可以理解为新闻流水线里的“执行者集合”。它们不再负责整体顺序，也不直接承担 runtime 细节，而是各自把某一种业务动作做深、做专，然后由 &lt;code&gt;NewsWorkflowApp&lt;/code&gt; 在上层把它们组织起来。&lt;/p&gt;
&lt;p&gt;从整体上看，这一层对应的正是新闻流水线的六个具体能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;热榜获取&lt;/li&gt;
&lt;li&gt;热点分类&lt;/li&gt;
&lt;li&gt;Web 搜索&lt;/li&gt;
&lt;li&gt;新闻生成&lt;/li&gt;
&lt;li&gt;新闻审阅&lt;/li&gt;
&lt;li&gt;最终成报&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而且这六类能力并不是同一种 agent 形态：&lt;br /&gt;
有的更像工具封装器，有的更像业务处理器，有的则已经是一个小型 workflow。正因为这样，这一层才值得单独讲。&lt;/p&gt;
&lt;h3&gt;(1) 热榜获取能力：&lt;code&gt;HotApiAgent&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;热榜获取能力由 &lt;code&gt;agents/hot_api_agent/agent.py&lt;/code&gt; 中的 &lt;code&gt;HotApiAgent&lt;/code&gt; 承担。它对应的是整条流水线最前面的输入阶段，也就是先把“今天值得看的热点池”取回来。&lt;/p&gt;
&lt;p&gt;这一层现在做的不只是简单调 API。它已经优先通过显式 &lt;code&gt;call_tool(...)&lt;/code&gt; 去调用下沉后的 &lt;code&gt;hot_api&lt;/code&gt; 本地工具，如果 runtime tool 调用失败，再回退到原来的直接类调用。因此它的能力形态很典型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对业务层来说，它是一个普通 agent&lt;/li&gt;
&lt;li&gt;对 AIOS 来说，它背后已经尽量走原生 tool runtime&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;HotApiAgent&lt;/code&gt; 的输入非常简单：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;API key&lt;/li&gt;
&lt;li&gt;指定平台或平台列表&lt;/li&gt;
&lt;li&gt;每个平台拉取多少条&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;输出则分成两份：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;hot_api.txt&lt;/code&gt;：供后续分类阶段读取的文本版热榜&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hot_api.json&lt;/code&gt;：保留结构化平台、话题和统计信息&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以它的价值不只是“抓到了热榜”，而是把多平台热点统一成后续阶段可消费的标准输入。&lt;/p&gt;
&lt;h3&gt;(2) 热点分类能力：&lt;code&gt;SortAgent&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;分类能力由 &lt;code&gt;agents/sort_agent/agent.py&lt;/code&gt; 中的 &lt;code&gt;SortAgent&lt;/code&gt; 承担。它的任务不是“理解整篇新闻”，而是把热榜阶段抓回来的原始热点标题，整理成后续搜索阶段可以并行处理的领域输入。&lt;/p&gt;
&lt;p&gt;这一层的输入是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;hot_api.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果存在，也会优先读 &lt;code&gt;hot_api.json&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它会先提取、去重、清洗热点标题，然后尝试用 &lt;code&gt;llm_chat_with_json_output(...)&lt;/code&gt; 做一次结构化分类。如果 LLM 分类没有返回有效结果，它还会回退到本地关键词规则分类。因此这一步本质上是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;LLM 分类优先，规则分类兜底。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;分类完成后，它不会只停在内存里，而是把每个领域分别保存成独立文件，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;社会热点与公共事务_api.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;科技与创新_api.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;商业与经济_api.txt&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步的意义非常大，因为它把“一个总的热点池”拆成了“按领域分组的待搜索列表”，也为后面 &lt;code&gt;search / generate / review&lt;/code&gt; 的领域级并发打下了基础。&lt;/p&gt;
&lt;p&gt;另外，&lt;code&gt;SortAgent&lt;/code&gt; 在最终落盘前还会做一次：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;route_story_category()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build_story_dedupe_key()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，它不是机械分类，而是已经和规则层开始联动，避免明显错误的栏目归属和重复题材扩散到后续阶段。&lt;/p&gt;
&lt;h3&gt;(3) 搜索能力：&lt;code&gt;WebSearchAgent&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;搜索能力由 &lt;code&gt;agents/web_search_agent/agent.py&lt;/code&gt; 中的 &lt;code&gt;WebSearchAgent&lt;/code&gt; 承担。它的输入已经不是“所有热点”，而是前一步分类后每个领域下的一组热点主题。&lt;/p&gt;
&lt;p&gt;这一层很值得讲的点有两个：&lt;/p&gt;
&lt;p&gt;第一，它不是只搜一条，而是&lt;strong&gt;按分类、按 topic 批量处理&lt;/strong&gt;。&lt;br /&gt;
第二，它虽然已经把 &lt;code&gt;web_search&lt;/code&gt; 下沉成了 AIOS 本地工具，但仍然保留了&lt;strong&gt;单 topic 子进程隔离&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;也就是说，现在真实链路更像：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;WebSearchAgent -&amp;gt; topic_worker 子进程 -&amp;gt; 优先 call_tool(&quot;web_search&quot;) -&amp;gt; ToolManager -&amp;gt; 本地工具&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;如果 runtime tool 路径失败，再回退到原来的直接搜索工具调用。&lt;/p&gt;
&lt;p&gt;这种设计非常工程化，因为它同时兼顾了两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;逐步下沉能力到 AIOS tool runtime&lt;/li&gt;
&lt;li&gt;不牺牲原来“单 topic 出错不会拖死整轮搜索”的隔离性&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一层的输出也不是一个总结果对象，而是一组标准工件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;*_search.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;*_image.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;搜索元数据文件&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其中 &lt;code&gt;*_search.txt&lt;/code&gt; 进入生成阶段，&lt;code&gt;*_image.txt&lt;/code&gt; 则为后面日报插图能力提供候选来源。&lt;br /&gt;
所以搜索能力本质上是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;从“主题”生产“可生成、可展示、可追溯”的搜索工件。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;(4) 新闻生成能力：&lt;code&gt;ParallelNewsTest + Title/Summary/Content/Judge&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;新闻生成能力是这一层里结构最特别的一类。它不是单个 agent 完成，而是由 &lt;code&gt;agents/news_generation_agent&lt;/code&gt; 下的一组子 agent 协同完成。当前主入口由 &lt;code&gt;ParallelNewsTest&lt;/code&gt; 承担。&lt;/p&gt;
&lt;p&gt;这一层的输入是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单个 topic 对应的 &lt;code&gt;*_search.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;topic 名称&lt;/li&gt;
&lt;li&gt;domain 分类&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正的生成过程不是“一次 llm_chat 直接出整稿”，而是并行拆成三部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TitleAgent&lt;/code&gt; 生成标题&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SummaryAgent&lt;/code&gt; 生成摘要&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ContentAgent&lt;/code&gt; 生成正文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三个部分会并行运行，然后各自交给 &lt;code&gt;JudgeAgent&lt;/code&gt; 做局部评审；如果某一部分没通过，还会按反馈单独重试。只有标题、摘要、正文全部通过局部评审，整篇稿子才会被保存成 &lt;code&gt;_news.txt&lt;/code&gt; 和 &lt;code&gt;_sources.json&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;因此，这里的“生成能力”并不是一个简单生成器，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一个“并行生成 + 局部评审 + 局部重试”的微型生产线。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;更重要的是，在当前项目里，这一层之前已经先经过编排层的 &lt;code&gt;generation_gate&lt;/code&gt;。也就是说，它只负责“把值得写的题材写好”，而不是负责决定“该不该写”。这让能力边界变得非常清楚。&lt;/p&gt;
&lt;h3&gt;(5) 审阅能力：&lt;code&gt;WorkflowAgent&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;审阅能力由 &lt;code&gt;agents/workflow_agent/agent.py&lt;/code&gt; 中的 &lt;code&gt;WorkflowAgent&lt;/code&gt; 承担。这一层和前面的生成能力不同，它更像一个小型的 agent workflow，而不是一个单步处理器。&lt;/p&gt;
&lt;p&gt;它的输入是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;_news.txt&lt;/code&gt; 新闻稿&lt;/li&gt;
&lt;li&gt;所属领域 domain&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后它会拉起一组专门的审阅 agent，包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;描述优化&lt;/li&gt;
&lt;li&gt;禁用词检测&lt;/li&gt;
&lt;li&gt;结构优化&lt;/li&gt;
&lt;li&gt;事实核查&lt;/li&gt;
&lt;li&gt;最终判断&lt;/li&gt;
&lt;li&gt;以及对应领域专家&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，这一层并不是“再润色一下”这么简单，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;围绕成稿再跑一轮质量工作流。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在运行方式上，它会先让领域专家做初步处理，然后再进入审阅 workflow。也就是说，审阅阶段并不是单纯用一个通用 judge，而是同时结合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;通用质量检查&lt;/li&gt;
&lt;li&gt;领域专家视角&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后通过的稿件会输出成 &lt;code&gt;*_reviewed.txt&lt;/code&gt;。&lt;br /&gt;
所以能力层里，&lt;code&gt;WorkflowAgent&lt;/code&gt; 是最接近“多 agent 协作子系统”的一个点，它也保留了你比赛时期那种“agent 协作”的味道，只不过现在被收进了更稳定的业务主线里。&lt;/p&gt;
&lt;h3&gt;(6) 成报能力：&lt;code&gt;MakerAgent&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;最后的成报能力由 &lt;code&gt;agents/maker_agent/agent.py&lt;/code&gt; 中的 &lt;code&gt;MakerAgent&lt;/code&gt; 承担。它不是单纯把几篇文章拼起来，而是负责把整条流水线前面产出的内容，收口成可以真正面向前端和用户展示的日报产品。&lt;/p&gt;
&lt;p&gt;它会做的事情包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;收集各栏目的 &lt;code&gt;_news.txt / _reviewed.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;读取对应 &lt;code&gt;sources.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;再次做 &lt;code&gt;publishability&lt;/code&gt; 判断&lt;/li&gt;
&lt;li&gt;去重&lt;/li&gt;
&lt;li&gt;栏目重路由&lt;/li&gt;
&lt;li&gt;过滤展示信源&lt;/li&gt;
&lt;li&gt;生成日报总览和亮点&lt;/li&gt;
&lt;li&gt;输出最终的 &lt;code&gt;txt / json / html&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，它不是一个简单的“renderer”，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;最终出版装配器。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;它还负责给日报打上时间戳，因此每次运行都会产出新的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;新闻报_时间戳.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;新闻报_时间戳.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;新闻报_时间戳.html&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步很关键，因为从 &lt;code&gt;MakerAgent&lt;/code&gt; 开始，系统产出的东西已经不再只是“给下一步 agent 用的中间文件”，而是前端、博客页面、dashboard、API 都可以直接消费的最终内容。&lt;/p&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;p&gt;如果把这一层再压缩成一句话，那么能力层真正做的事情就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;把新闻工作流拆成一组边界清晰的业务执行者：有人负责抓输入，有人负责整理主题，有人负责生产搜索工件，有人负责并行生成，有人负责审阅把关，最后再由专门的出版器完成成报。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;因此，当前 AIOS-NP 的能力层并不是“很多 agent 堆在一起”，而是一组角色明确、输入输出稳定、能被编排层可靠调度的业务执行组件。也正因为这些组件边界足够清楚，前面的编排层、规则层和 AIOS 接入层才能真正发挥作用。&lt;/p&gt;
&lt;h2&gt;7. 在线化层&lt;/h2&gt;
&lt;p&gt;如果说前面的编排层、规则层、能力层解决的是“日报怎么生成”，那么在线化层解决的就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;这套能力如何持续在线运行、如何被调度、如何被观察、又如何被前端与外部系统消费。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;也正因为有了这一层，AIOS-NP 才不再只是一个“跑一次就结束”的比赛型脚本，而是变成了一个长期在线的新闻系统。&lt;/p&gt;
&lt;h3&gt;(1) 运行管理：&lt;code&gt;ecosystem.py&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;在线化层最核心的文件是 &lt;code&gt;apps/news_app/ecosystem.py&lt;/code&gt;。它并不直接参与新闻生成细节，而是站在工作流外面，负责管理“每一次运行”。&lt;/p&gt;
&lt;p&gt;这一层里最关键的角色是 &lt;code&gt;NewsRunManager&lt;/code&gt;。它的职责可以概括为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;接受一次新的运行请求&lt;/li&gt;
&lt;li&gt;判断当前是否已有任务在跑&lt;/li&gt;
&lt;li&gt;为这次任务分配 &lt;code&gt;run_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;启动后台线程执行 &lt;code&gt;NewsWorkflowApp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;持续收集运行事件&lt;/li&gt;
&lt;li&gt;将 run / state / metrics / snapshot 落盘保存&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，&lt;code&gt;NewsRunManager&lt;/code&gt; 不是新闻生产者，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;新闻工作流的运行控制器。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;当前项目里，真正触发一轮工作流的入口就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;手动触发：&lt;code&gt;trigger_run(...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;启动触发：&lt;code&gt;source=&quot;startup&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;调度触发：&lt;code&gt;source=&quot;scheduler&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一旦进入 &lt;code&gt;_execute_run(...)&lt;/code&gt;，它会创建一个真正的 &lt;code&gt;NewsWorkflowApp&lt;/code&gt;，并把 &lt;code&gt;event_handler&lt;/code&gt; 传进去。这样前面编排层持续 &lt;code&gt;emit&lt;/code&gt; 出来的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;run_started&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stage_started&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stage_finished&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;run_finished&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些事件，就会被在线层接住，进一步更新这次 run 的阶段摘要、状态文件和指标文件。&lt;/p&gt;
&lt;p&gt;因此，这一层的重要意义在于：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;它把编排层里“正在发生什么”持续变成“系统外面可观察的运行记录”。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;(2) 状态持久化：run / state / metrics / snapshot&lt;/h3&gt;
&lt;p&gt;如果只有 &lt;code&gt;NewsRunManager&lt;/code&gt;，系统还只是“能后台跑”。真正让它变成在线系统的，是它会把运行过程中的几个关键视图长期保存下来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;run&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;metrics&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;snapshot&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这几类数据的作用并不相同。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;run&lt;/code&gt;
记录一轮任务的基本身份信息，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;run_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mode&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;source&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;created_at / started_at / finished_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;最终成功还是失败&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;state&lt;/code&gt;
更偏“当前工作流走到哪一步”，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;各阶段状态&lt;/li&gt;
&lt;li&gt;是否已有 report&lt;/li&gt;
&lt;li&gt;哪些文件已经生成&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;metrics&lt;/code&gt;
更偏“本轮结果质量如何”，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文章数量&lt;/li&gt;
&lt;li&gt;来源数量&lt;/li&gt;
&lt;li&gt;高亮数量&lt;/li&gt;
&lt;li&gt;各阶段耗时&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;snapshot&lt;/code&gt;
更偏“给外部系统直接消费的最新快照”，它会把运行结果收敛成一份更适合前端和 API 消费的数据视图。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;因此，在线化层不是只保存“日志”，而是在主动构建：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;运行视图、状态视图、指标视图和结果快照视图。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这也是为什么博客前端、dashboard、latest report API 最后都能建立在它之上。&lt;/p&gt;
&lt;h3&gt;(3) 自动调度：&lt;code&gt;NewsScheduler&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ecosystem.py&lt;/code&gt; 里的第二个关键角色是 &lt;code&gt;NewsScheduler&lt;/code&gt;。它的任务不是“生成新闻”，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;根据配置，让新闻系统在规定时间自动启动一轮 workflow。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;它会根据：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;auto_run_enabled&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auto_run_time&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auto_run_mode&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;决定：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;是否开启自动调度&lt;/li&gt;
&lt;li&gt;每天什么时候触发&lt;/li&gt;
&lt;li&gt;触发时采用串行还是并行模式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;调度器的运行逻辑其实很朴素：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务启动时拉起后台轮询线程&lt;/li&gt;
&lt;li&gt;每隔一段时间检查当前时间&lt;/li&gt;
&lt;li&gt;如果到达设定时刻，且当天还没跑过，就自动调用 &lt;code&gt;trigger_run(...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里最值得讲的点，不是“会定时”，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;调度能力并没有侵入业务 workflow 本身，而是作为在线层额外包住了编排层。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;因此，&lt;code&gt;NewsWorkflowApp&lt;/code&gt; 仍然专注于“怎么跑一轮新闻日报”，而 &lt;code&gt;NewsScheduler&lt;/code&gt; 只负责“什么时候启动它”。这种分层非常干净。&lt;/p&gt;
&lt;h3&gt;(4) 服务暴露：&lt;code&gt;service.py&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;如果说 &lt;code&gt;ecosystem.py&lt;/code&gt; 解决的是“怎么管理系统内部运行”，那么 &lt;code&gt;apps/news_app/service.py&lt;/code&gt; 解决的就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;怎么把这套运行能力通过 Web 服务暴露出去。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这一层使用 FastAPI，把新闻系统包装成一个长期可访问的服务。它在 &lt;code&gt;lifespan&lt;/code&gt; 中完成几件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;加载环境变量&lt;/li&gt;
&lt;li&gt;初始化 &lt;code&gt;NewsRunManager&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;初始化 &lt;code&gt;AgentRegistryManager&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;初始化 &lt;code&gt;NewsScheduler&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;在服务启动时自动启动调度器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样，服务一旦起来，就不是一个空壳，而是一套已经带有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;运行管理器&lt;/li&gt;
&lt;li&gt;agent 注册能力&lt;/li&gt;
&lt;li&gt;自动调度器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;的在线系统。&lt;/p&gt;
&lt;p&gt;对外暴露的接口大体可以分成几类：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;健康与状态接口&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/health&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/status&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;运行管理接口&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/runs&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/runs/{run_id}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/runs/{run_id}/state&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/runs/{run_id}/metrics&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;结果消费接口&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/news/latest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/reports/latest/html&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/ecosystem/output/report/latest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;agent 管理接口&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/api/agents&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/agents/register&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/agents/{agent_id}/run&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;因此，&lt;code&gt;service.py&lt;/code&gt; 的定位不是“又写一套业务逻辑”，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;把内部工作流、运行状态和最终日报包装成外部系统可直接访问的 HTTP 能力。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;(5) 可视化观察：&lt;code&gt;dashboard.py&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;除了 API，当前系统还提供了一套 dashboard。它由 &lt;code&gt;apps/news_app/dashboard.py&lt;/code&gt; 负责生成 HTML。&lt;/p&gt;
&lt;p&gt;这部分很有产品意味，因为它不是单纯把 JSON 原样打印出来，而是把在线系统最重要的几个视角整理成可读界面：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前是否有任务在跑&lt;/li&gt;
&lt;li&gt;最新一次运行状态&lt;/li&gt;
&lt;li&gt;最近几次 run&lt;/li&gt;
&lt;li&gt;各阶段成功/失败与耗时&lt;/li&gt;
&lt;li&gt;最新 snapshot / metrics&lt;/li&gt;
&lt;li&gt;最新 report 的访问入口&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，dashboard 的作用不是“替代 API”，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;给开发者、维护者和演示场景提供一个更直观的系统观察面。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这点对于你的项目很重要，因为它进一步说明：&lt;/p&gt;
&lt;p&gt;AIOS-NP 已经不是“在终端里跑完就算结束”，而是开始具备真正的运维与展示面。&lt;/p&gt;
&lt;h3&gt;(6) 历史保留与 latest 视图&lt;/h3&gt;
&lt;p&gt;在线化层还有一个很容易被忽略、但非常实用的设计：&lt;br /&gt;
系统既保留历史日报，又维护 latest 视图。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;output/&lt;/code&gt; 目录下会持续保留历史 &lt;code&gt;新闻报_时间戳.txt/json/html&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ecosystem/&lt;/code&gt; 下也会保留历史 run、state、metrics、snapshot&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;与此同时，服务层又提供：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;latest_snapshot()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;latest_state()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;latest_metrics()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;latest_output_report()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些“最新视图”接口。&lt;/p&gt;
&lt;p&gt;所以这套系统并不是“历史和最新只能二选一”，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一方面保留完整历史，另一方面对外统一暴露一个便于前端消费的 latest 入口。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这也是为什么博客前端现在既能默认展示最新日报，也能进一步扩展成查看不同日期、不同时间的历史日报。&lt;/p&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;p&gt;如果把在线化层再压缩成一句话，那么它真正完成的事情就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;在编排层外面补上运行管理、调度、状态持久化、服务暴露和可视化观察，使新闻系统从“一次性 workflow”真正升级成“持续在线可提供服务的应用”。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;因此，当前 AIOS-NP 的在线化层，并不是附属功能，而是把比赛时期的新闻流水线真正产品化、服务化的关键一步。&lt;/p&gt;
</content:encoded></item><item><title>Docker 入门：镜像、容器、数据卷、网络到 Compose</title><link>https://owen571.top/posts/study/docker/01-docker-%E5%85%A5%E9%97%A8-%E9%95%9C%E5%83%8F%E5%AE%B9%E5%99%A8%E5%88%B0-compose/</link><guid isPermaLink="true">https://owen571.top/posts/study/docker/01-docker-%E5%85%A5%E9%97%A8-%E9%95%9C%E5%83%8F%E5%AE%B9%E5%99%A8%E5%88%B0-compose/</guid><description>从概念到实操的完整主线，先把镜像与容器的关系理顺，再走到 Dockerfile 与 Compose。</description><pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Docker 教程&lt;/h1&gt;
&lt;p&gt;这篇笔记把我看视频时记下的要点，和公众号里的系统化内容，整理成了一篇从概念到实操都比较完整的 Docker 入门教程。目标不是把所有命令死记硬背，而是先建立一条清晰主线：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;镜像 -&amp;gt; 容器 -&amp;gt; 数据卷 -&amp;gt; 网络 -&amp;gt; Dockerfile -&amp;gt; Compose&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;只要这条主线理顺了，后面的命令基本都能串起来。&lt;/p&gt;
&lt;h2&gt;1. Docker 是什么&lt;/h2&gt;
&lt;p&gt;Docker 是一种&lt;strong&gt;容器化技术&lt;/strong&gt;。它可以把应用程序、依赖库、运行环境和配置一起打包，让应用在不同机器上都以尽量一致的方式运行。&lt;/p&gt;
&lt;p&gt;Docker 想解决的核心问题是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地能跑，换台机器就跑不起来&lt;/li&gt;
&lt;li&gt;部署过程复杂，环境经常配错&lt;/li&gt;
&lt;li&gt;不同项目依赖冲突&lt;/li&gt;
&lt;li&gt;手动安装软件、配置环境太繁琐&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Docker 的思路是：把应用运行所需的一切，尽量放进一个独立的容器环境里。&lt;/p&gt;
&lt;h3&gt;1.1 为什么 Docker 很流行&lt;/h3&gt;
&lt;p&gt;Docker 常见的优点有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;轻量：容器共享宿主机内核，比虚拟机小很多，启动也更快&lt;/li&gt;
&lt;li&gt;可移植：同一个镜像可以在不同机器上运行&lt;/li&gt;
&lt;li&gt;隔离性：不同容器之间尽量互不干扰&lt;/li&gt;
&lt;li&gt;标准化：构建、分发、运行都有统一命令和工具链&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;1.2 Docker 和虚拟机的区别&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;Docker 容器&lt;/th&gt;
&lt;th&gt;虚拟机&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;本质&lt;/td&gt;
&lt;td&gt;共享宿主机内核的进程级隔离&lt;/td&gt;
&lt;td&gt;模拟完整硬件并运行独立操作系统&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;启动速度&lt;/td&gt;
&lt;td&gt;很快，通常秒级甚至更快&lt;/td&gt;
&lt;td&gt;相对较慢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;资源占用&lt;/td&gt;
&lt;td&gt;较小&lt;/td&gt;
&lt;td&gt;较大&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;隔离性&lt;/td&gt;
&lt;td&gt;足够强，但通常弱于虚拟机&lt;/td&gt;
&lt;td&gt;更强&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;适用场景&lt;/td&gt;
&lt;td&gt;应用部署、开发测试、微服务&lt;/td&gt;
&lt;td&gt;强隔离、多操作系统环境&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一句话记忆：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;虚拟机&lt;/strong&gt;更像“整台电脑里的另一台电脑”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker&lt;/strong&gt;更像“隔离出来的应用运行沙盒”&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. Docker 的核心概念&lt;/h2&gt;
&lt;p&gt;Docker 最重要的 6 个概念如下：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;概念&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;可以怎么理解&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;镜像（Image）&lt;/td&gt;
&lt;td&gt;创建容器的模板&lt;/td&gt;
&lt;td&gt;类似“安装包”或“快照”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;容器（Container）&lt;/td&gt;
&lt;td&gt;镜像运行后的实例&lt;/td&gt;
&lt;td&gt;类似“安装出来并正在运行的软件”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dockerfile&lt;/td&gt;
&lt;td&gt;构建镜像的脚本&lt;/td&gt;
&lt;td&gt;类似“自动化装机说明书”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;仓库（Registry）&lt;/td&gt;
&lt;td&gt;存放镜像的地方&lt;/td&gt;
&lt;td&gt;类似“应用商店 / 镜像服务器”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据卷（Volume）&lt;/td&gt;
&lt;td&gt;持久化数据的方式&lt;/td&gt;
&lt;td&gt;类似“外挂硬盘 / 数据目录”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;网络（Network）&lt;/td&gt;
&lt;td&gt;容器之间通信的方式&lt;/td&gt;
&lt;td&gt;类似“局域网”&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;理解 Docker 时，最容易混淆的是&lt;strong&gt;镜像&lt;/strong&gt;和&lt;strong&gt;容器&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;镜像是静态的，只读的模板&lt;/li&gt;
&lt;li&gt;容器是动态的，运行中的实例&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nginx&lt;/code&gt; 镜像可以创建很多个容器&lt;/li&gt;
&lt;li&gt;每个容器都有自己的运行状态、日志、网络配置&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 安装与验证&lt;/h2&gt;
&lt;p&gt;因为我平时主要在 macOS / Windows 上使用 Docker，这里以 Docker Desktop 为主。&lt;/p&gt;
&lt;h3&gt;3.1 安装方式&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;macOS：安装 Docker Desktop，或者用 Homebrew&lt;/li&gt;
&lt;li&gt;Windows：安装 Docker Desktop&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;macOS 常见安装命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew install --cask docker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装完成后，需要&lt;strong&gt;手动启动 Docker Desktop&lt;/strong&gt;。只安装命令行还不够，后台服务必须真的运行起来。&lt;/p&gt;
&lt;h3&gt;3.2 验证是否安装成功&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker --version
docker info
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更推荐第一次就跑一下官方测试镜像：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run hello-world
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果能看到 Docker 输出的欢迎信息，说明 Docker 已经安装并运行正常。&lt;/p&gt;
&lt;h3&gt;3.3 架构差异补充&lt;/h3&gt;
&lt;p&gt;在 macOS 上，我会经常遇到镜像架构问题。因为现在很多 Mac 是 ARM64，但有些镜像默认是 AMD64。&lt;/p&gt;
&lt;p&gt;这时可能会看到类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker pull --platform linux/amd64 nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条命令的意思是：强制拉取指定平台架构的镜像。&lt;/p&gt;
&lt;p&gt;Docker Desktop 往往会借助 QEMU 去兼容不同架构，但并不是所有镜像都适合跨架构运行，所以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;能用原生 ARM64 镜像最好&lt;/li&gt;
&lt;li&gt;不行再考虑 &lt;code&gt;--platform&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.4 国内镜像源&lt;/h3&gt;
&lt;p&gt;由于担心镜像源在海外访问缓慢，对于docker desktop来说，我们可以在这里配置一下国内源：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage.B03oXeqQ.png&amp;amp;w=1782&amp;amp;h=784&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. 镜像：Docker 的起点&lt;/h2&gt;
&lt;p&gt;镜像是创建容器的模板。几乎所有 Docker 操作，都是围绕镜像开始的。&lt;/p&gt;
&lt;h3&gt;4.1 镜像名怎么读&lt;/h3&gt;
&lt;p&gt;下面这条命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker pull docker.io/library/nginx:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以拆成三部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker.io&lt;/code&gt;：仓库注册表地址（registry）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;library/nginx&lt;/code&gt;：镜像仓库（repository）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;latest&lt;/code&gt;：标签（tag）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大多数情况下都可以简写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker pull nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为官方仓库和 &lt;code&gt;latest&lt;/code&gt; 标签都可以省略。&lt;/p&gt;
&lt;h3&gt;4.2 常用镜像命令&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker pull nginx
docker pull nginx:1.27
docker images
docker search mysql
docker inspect nginx
docker history nginx
docker tag nginx:latest my-nginx:v1
docker rmi nginx
docker save -o nginx.tar nginx:latest
docker load -i nginx.tar
docker login
docker push your_username/my-image:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.3 镜像命令怎么理解&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;命令&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker pull&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;拉取镜像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker images&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看本地镜像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;搜索公共镜像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker inspect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看镜像详细信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker history&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看镜像分层历史&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker tag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;给镜像打新标签&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker rmi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;删除镜像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker save / load&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;导出 / 导入镜像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker login / push&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;登录并推送镜像到仓库&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;4.4 镜像修改的本质&lt;/h3&gt;
&lt;p&gt;如果只是临时修改容器里的文件，那只是改了&lt;strong&gt;容器&lt;/strong&gt;，不是改了&lt;strong&gt;镜像&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;真正推荐的“修改镜像”方式是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;写 Dockerfile&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;docker build&lt;/code&gt; 重新构建镜像&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 容器：镜像运行之后的实例&lt;/h2&gt;
&lt;p&gt;镜像只是模板，真正运行起来的是容器。&lt;/p&gt;
&lt;h3&gt;5.1 &lt;code&gt;docker run&lt;/code&gt; 是最核心的命令&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker run -d -p 80:80 --name nginx-container nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条命令做了几件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果本地没有 &lt;code&gt;nginx&lt;/code&gt; 镜像，会先自动拉取&lt;/li&gt;
&lt;li&gt;基于镜像创建容器&lt;/li&gt;
&lt;li&gt;把容器放到后台运行&lt;/li&gt;
&lt;li&gt;把容器的 80 端口映射到宿主机的 80 端口&lt;/li&gt;
&lt;li&gt;给容器起名叫 &lt;code&gt;nginx-container&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5.2 &lt;code&gt;docker run&lt;/code&gt; 常用参数&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;参数&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;后台运行&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-p 宿主机端口:容器端口&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;端口映射&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;给容器起名字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-it&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;交互式进入容器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-v&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;挂载目录或卷&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--rm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;容器停止后自动删除&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;传环境变量&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--network&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定网络&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--restart always&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自动重启策略&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-u&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定用户运行&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;5.3 常用容器命令&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker run -d -p 80:80 nginx
docker run -d --name my_container -p 8080:8080 tomcat:latest
docker run -it --rm ubuntu /bin/bash
docker run -d --restart always nginx

docker ps
docker ps -a
docker ps -l
docker ps -q
docker ps -aq

docker stop nginx-container
docker start nginx-container
docker create nginx
docker rm nginx-container
docker rm -f nginx-container

docker logs nginx-container
docker logs -f nginx-container
docker stats
docker inspect nginx-container
docker exec -it nginx-container /bin/bash
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.4 这些命令最容易混淆的点&lt;/h3&gt;
&lt;h4&gt;&lt;code&gt;run&lt;/code&gt;、&lt;code&gt;create&lt;/code&gt;、&lt;code&gt;start&lt;/code&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker run&lt;/code&gt;：创建并启动&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker create&lt;/code&gt;：只创建，不启动&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker start&lt;/code&gt;：启动一个已经存在但停止了的容器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以如果你反复执行 &lt;code&gt;docker run&lt;/code&gt;，就会不断创建新容器；而不是“重启旧容器”。&lt;/p&gt;
&lt;h4&gt;&lt;code&gt;ps&lt;/code&gt; 和 &lt;code&gt;ps -a&lt;/code&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker ps&lt;/code&gt;：只看正在运行的容器&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker ps -a&lt;/code&gt;：看所有容器，包括退出的&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;&lt;code&gt;logs&lt;/code&gt; 和 &lt;code&gt;exec&lt;/code&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker logs&lt;/code&gt;：看容器输出日志&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker exec&lt;/code&gt;：进入容器内部执行命令&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5.5 进入容器内部&lt;/h3&gt;
&lt;p&gt;最常用的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec -it nginx-container /bin/bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果镜像比较轻量（例如 Alpine），里面可能没有 &lt;code&gt;bash&lt;/code&gt;，要改成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec -it nginx-container /bin/sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 数据持久化：Bind Mount 和 Volume&lt;/h2&gt;
&lt;p&gt;容器有一个很重要的特点：&lt;strong&gt;容器删了，容器里的数据可能也就没了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;所以数据库、上传文件、缓存目录等，通常都不能只存在容器内部，而需要挂载到外部。&lt;/p&gt;
&lt;h3&gt;6.1 Bind Mount：绑定宿主机目录&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker run -v /宿主机路径:/容器路径 nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run -d -p 80:80 -v /Users/owen/site:/usr/share/nginx/html nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;宿主机路径可见、好找&lt;/li&gt;
&lt;li&gt;修改宿主机文件，容器里会同步&lt;/li&gt;
&lt;li&gt;很适合开发环境&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;6.2 Volume：命名卷&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker volume create mydata
docker run -d -v mydata:/data nginx
docker volume inspect mydata
docker volume ls
docker volume rm mydata
docker volume prune -a
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;路径由 Docker 管理&lt;/li&gt;
&lt;li&gt;更适合持久化数据&lt;/li&gt;
&lt;li&gt;不依赖我手动维护某个宿主机目录&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;6.3 Bind Mount 和 Volume 的区别&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;写法&lt;/th&gt;
&lt;th&gt;适合场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bind Mount&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-v 宿主机路径:容器路径&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;开发环境、直接编辑文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-v 卷名:容器路径&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;持久化数据、数据库&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;7. Dockerfile：如何制作自己的镜像&lt;/h2&gt;
&lt;p&gt;Dockerfile 可以理解成“制作镜像的图纸”。&lt;/p&gt;
&lt;p&gt;你不需要手工进入容器修改环境，而是把构建步骤写进 Dockerfile，让 Docker 自动构建镜像。&lt;/p&gt;
&lt;h3&gt;7.1 一个最小可运行示例&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;FROM python:3.13-slim

WORKDIR /app

COPY . .

RUN pip install -r requirements.txt

# 这是一个暴露容器端口的提示，不会自动映射端口
EXPOSE 8000

# CMD 只能有一个最终生效的定义
CMD [&quot;python3&quot;, &quot;main.py&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构建命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker build -t my-python-app:1.0 .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run -d -p 8000:8000 my-python-app:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.2 Dockerfile 常用指令&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指令&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FROM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定基础镜像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WORKDIR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定工作目录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COPY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;复制文件到镜像中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RUN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;构建镜像时执行命令&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;设置环境变量&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPOSE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;声明容器监听端口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CMD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定容器启动默认命令&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENTRYPOINT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定容器主命令&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;7.3 &lt;code&gt;CMD&lt;/code&gt; 和 &lt;code&gt;ENTRYPOINT&lt;/code&gt; 的区别&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CMD&lt;/code&gt;：默认命令，容易被 &lt;code&gt;docker run&lt;/code&gt; 后面的命令覆盖&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ENTRYPOINT&lt;/code&gt;：主命令，通常不会被简单覆盖&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;入门阶段先记住：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;大多数场景先会用 &lt;code&gt;CMD&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;需要更强约束时再考虑 &lt;code&gt;ENTRYPOINT&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;8. Docker 网络&lt;/h2&gt;
&lt;p&gt;Docker 网络的作用是让容器之间可以互相通信，同时和宿主机网络进行隔离。&lt;/p&gt;
&lt;h3&gt;8.1 常见网络模式&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;模式&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bridge&lt;/td&gt;
&lt;td&gt;默认桥接模式，最常见&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;host&lt;/td&gt;
&lt;td&gt;容器直接使用宿主机网络&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;不分配网络&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;8.2 常用网络命令&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker network ls
docker network create network1
docker network rm network1
docker run -d --network host nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.3 为什么自定义网络很重要&lt;/h3&gt;
&lt;p&gt;默认情况下，容器在 bridge 网络中可以通过 IP 通信。但如果你自己创建一个网络，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker network create network1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后让多个容器都加入这个网络，它们就可以&lt;strong&gt;通过容器名互相访问&lt;/strong&gt;，这比记 IP 更方便。&lt;/p&gt;
&lt;p&gt;这点在数据库容器 + Web 容器配合时非常重要。&lt;/p&gt;
&lt;h2&gt;9. Docker Compose：管理多个容器&lt;/h2&gt;
&lt;p&gt;如果一个应用只需要一个容器，那么 &lt;code&gt;docker run&lt;/code&gt; 就够了。&lt;/p&gt;
&lt;p&gt;但一旦项目里有多个容器，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;web&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;db&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;redis&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你就会发现手写一长串 &lt;code&gt;docker run&lt;/code&gt; 特别麻烦。这时就轮到 Docker Compose 出场了。&lt;/p&gt;
&lt;h3&gt;9.1 Compose 是什么&lt;/h3&gt;
&lt;p&gt;Docker Compose 是一种&lt;strong&gt;多容器编排工具&lt;/strong&gt;。&lt;br /&gt;
它用一个 YAML 文件把多个容器的配置写在一起，然后一条命令统一启动。&lt;/p&gt;
&lt;p&gt;一句话：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker run&lt;/code&gt; 管单个容器&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker compose&lt;/code&gt; 管一组相关容器
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-1.paTzzKXr.png&amp;amp;w=790&amp;amp;h=386&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9.2 Compose 和 &lt;code&gt;docker run&lt;/code&gt; 的区别&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;&lt;code&gt;docker run&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;docker compose up&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;管理对象&lt;/td&gt;
&lt;td&gt;单个容器&lt;/td&gt;
&lt;td&gt;一组服务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;配置方式&lt;/td&gt;
&lt;td&gt;命令行参数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt; / &lt;code&gt;compose.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;适合场景&lt;/td&gt;
&lt;td&gt;临时测试、单容器运行&lt;/td&gt;
&lt;td&gt;多容器项目、开发环境&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可维护性&lt;/td&gt;
&lt;td&gt;命令长了后难维护&lt;/td&gt;
&lt;td&gt;配置集中，易于团队协作&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;9.3 从两条 &lt;code&gt;docker run&lt;/code&gt; 到一个 Compose 文件&lt;/h3&gt;
&lt;p&gt;下面这组命令用于启动 MongoDB 和 Mongo Express：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker network create network1

docker run -d \
  --name my_mongodb \
  -e MONGO_INITDB_ROOT_USERNAME=name \
  -e MONGO_INITDB_ROOT_PASSWORD=pass \
  -v /my/datadir:/data/db \
  --network network1 \
  mongo

docker run -d \
  --name my_mongodb_express \
  -p 8081:8081 \
  -e ME_CONFIG_MONGODB_SERVER=my_mongodb \
  -e ME_CONFIG_MONGODB_ADMINUSERNAME=name \
  -e ME_CONFIG_MONGODB_ADMINPASSWORD=pass \
  --network network1 \
  mongo-express
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;等价的 Compose 文件可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  my_mongodb:
    image: mongo
    environment:
      MONGO_INITDB_ROOT_USERNAME: name
      MONGO_INITDB_ROOT_PASSWORD: pass
    volumes:
      - /my/datadir:/data/db

  my_mongodb_express:
    image: mongo-express
    ports:
      - &quot;8081:8081&quot;
    environment:
      ME_CONFIG_MONGODB_SERVER: my_mongodb
      ME_CONFIG_MONGODB_ADMINUSERNAME: name
      ME_CONFIG_MONGODB_ADMINPASSWORD: pass
    depends_on:
      - my_mongodb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有两个很重要的点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Compose 会自动为项目创建默认网络，所以通常不用手动 &lt;code&gt;docker network create&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;同一个 Compose 项目里的服务，默认可以通过&lt;strong&gt;服务名&lt;/strong&gt;互相访问&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9.4 Compose 常用命令&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d
docker compose down
docker compose stop
docker compose start
docker compose ps
docker compose logs
docker compose logs -f
docker compose exec web sh
docker compose -f 路径/compose.yaml up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.5 这些 Compose 命令怎么理解&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;命令&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;后台启动所有服务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose down&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;停止并删除服务相关容器和网络&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;只停止，不删除&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;启动已存在的服务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose ps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看服务状态&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose logs -f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;持续查看日志&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose exec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;进入某个服务容器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker compose -f 文件名 ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定 Compose 文件路径&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;9.6 Compose 适合什么场景&lt;/h3&gt;
&lt;p&gt;Compose 属于轻量级编排工具，适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;个人开发&lt;/li&gt;
&lt;li&gt;本地调试&lt;/li&gt;
&lt;li&gt;单机部署&lt;/li&gt;
&lt;li&gt;小规模项目&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是企业级大规模集群编排，通常会进一步接触 Kubernetes。&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 入门：StateGraph、工具调用与记忆初探</title><link>https://owen571.top/posts/study/langgraph/01-langgraph-%E5%85%A5%E9%97%A8-stategraph-%E5%B7%A5%E5%85%B7%E4%B8%8E%E8%AE%B0%E5%BF%86%E5%88%9D%E6%8E%A2/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/01-langgraph-%E5%85%A5%E9%97%A8-stategraph-%E5%B7%A5%E5%85%B7%E4%B8%8E%E8%AE%B0%E5%BF%86%E5%88%9D%E6%8E%A2/</guid><description>从一个最小聊天图开始，把 StateGraph、节点、边、ToolNode、记忆与 time-travel 的直觉先搭起来。</description><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇更像“先把地图打开”。里面会提前碰到工具、记忆、human-in-the-loop 和 time-travel，但重点是先建立 LangGraph 整体长什么样的直觉。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;LangGraph 专为希望构建强大、适应性强的 AI 智能体的开发者而设计。比起LangChain，它支持更为复杂的自定义的操作。&lt;/p&gt;
&lt;p&gt;你可能会疑惑，我已经有了create_agent + middleware，啥场景不够用？有的兄弟有的，如果出现下面这些信号，就需要升级到LangGraph了！&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你需要明确的分支/循环（不是“让模型自己决定”）。&lt;/li&gt;
&lt;li&gt;你需要并行流程（fan-out/fan-in）。&lt;/li&gt;
&lt;li&gt;你要在固定步骤做人审、打断、恢复。&lt;/li&gt;
&lt;li&gt;你要可回放、可分叉、可精确恢复（durable execution）。&lt;/li&gt;
&lt;li&gt;你发现 middleware 里 if/else 越来越多，逻辑难维护。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当然，最常见的其实是混用两个，我们将create_agent作为能力节点，放进LangGraph中，而LangGraph负责全局编排。接下来，我们来简单入门一下LangGraph。&lt;/p&gt;
&lt;h2&gt;2. 构建一个聊天机器人&lt;/h2&gt;
&lt;p&gt;我们先安装一下两个所需要的软件包，分别是langgraph和langsmith：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U langgraph langsmith
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;紧接着，我们用StateGraph构建聊天机器人，这个聊天机器人直接回复用户的消息。一个 StateGraph 对象将我们的聊天机器人结构定义为“状态机”。我们将添加 节点 来表示 LLM 和聊天机器人可以调用的函数，并添加 边 来指定机器人应如何在这些函数之间进行转换。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated

from typing_extensions import TypedDict

from langgraph.graph import StateGraph, START
from langgraph.graph.message import add_messages


class State(TypedDict):
    # Messages have the type &quot;list&quot;. The `add_messages` function
    # in the annotation defines how this state key should be updated
    # (in this case, it appends messages to the list, rather than overwriting them)
    messages: Annotated[list, add_messages]


graph_builder = StateGraph(State)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们的图现在可以处理两个关键任务&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个 节点 都可以接收当前 状态 作为输入，并输出状态的更新。&lt;/li&gt;
&lt;li&gt;对 消息 的更新将追加到现有列表而不是覆盖它，这得益于与 Annotated 语法一起使用的预构建 add_messages 函数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在，我们通过StateGraph对加节点加边，就可以构成一个可以运行的图，完整代码如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from typing import Annotated

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from typing_extensions import TypedDict

from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages

BASE_DIR = os.path.dirname(__file__)
load_dotenv(os.path.join(BASE_DIR, &quot;.env&quot;))


class State(TypedDict):
    messages: Annotated[list, add_messages]


graph_builder = StateGraph(State)

llm = init_chat_model(
    &quot;openai:gpt-4o-mini&quot;,
    base_url=os.environ.get(&quot;QIHANG_BASE_URL&quot;),
    api_key=os.environ.get(&quot;QIHANG_API&quot;),
)


def chatbot(state: State):
    # 这里必须返回 &quot;messages&quot;，否则不会写入 State.messages
    return {&quot;messages&quot;: [llm.invoke(state[&quot;messages&quot;])]}


# 将模型集成到节点
graph_builder.add_node(&quot;chatbot&quot;, chatbot)

# 添加入口和结束
graph_builder.add_edge(START, &quot;chatbot&quot;)
graph_builder.add_edge(&quot;chatbot&quot;, END)

# 编译图
graph = graph_builder.compile()


def visualize_graph() -&amp;gt; None:
    graph_obj = graph.get_graph()

    png_path = os.path.join(BASE_DIR, &quot;graph.png&quot;)
    try:
        with open(png_path, &quot;wb&quot;) as f:
            f.write(graph_obj.draw_mermaid_png())
        print(f&quot;[visualize] 已保存 PNG: {png_path}&quot;)
    except Exception as e:
        print(f&quot;[visualize] 生成 PNG 失败: {e}&quot;)
        print(&quot;[visualize] Mermaid 文本如下：&quot;)
        print(graph_obj.draw_mermaid())


if __name__ == &quot;__main__&quot;:
    visualize_graph()

    # LangGraph + add_messages 支持这种 message shorthand
    result = graph.invoke({&quot;messages&quot;: [(&quot;user&quot;, &quot;你好，介绍一下你自己&quot;)]})

    print(&quot;\n===== Final State =====&quot;)
    for msg in result[&quot;messages&quot;]:
        msg.pretty_print()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fgraph.BoXu0TgK.png&amp;amp;w=216&amp;amp;h=249&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;add_node&lt;/code&gt;接受的是一个可调用对象，普通函数符合了这个要求。而&lt;code&gt;State&lt;/code&gt;类，是我们的全局Schema。我们用&lt;code&gt;TypedDict&lt;/code&gt;进行了定义，添加了消息历史的更新方式&lt;code&gt;messages: Annotated[list, add_messages]&lt;/code&gt;。除了定义更新规则之外，还可能会定义状态结构、字段类型。&lt;/p&gt;
&lt;p&gt;这就是最基本的机器人创建啦，只有一个节点，两条边，model被invoke之后返回的是一个AIMessage，详见之前LangChain的核心组件Messages。&lt;/p&gt;
&lt;p&gt;另外一个重要的点，就是StateGraph种，node的返回值。这里是返回dict对状态就行增量更新（返回要修改的字段），当然也可以返回Command，用于更新状态+控制流跳转，比如：&lt;code&gt;Command(update={&quot;x&quot;: 1}, goto=&quot;next_node&quot;)&lt;/code&gt;或者&lt;code&gt;graph=Command.PARENT&lt;/code&gt;（子图场景）。&lt;/p&gt;
&lt;h2&gt;3. 添加工具&lt;/h2&gt;
&lt;h3&gt;(1) tool.invoke()&lt;/h3&gt;
&lt;p&gt;我们用Tavily API试试工具效果，这是一个让llm拥有网络搜索能力的工具。我们先&lt;code&gt;from langchain_tavily import TavilySearch&lt;/code&gt;（记得安装依赖和加载API），然后创建工具&lt;code&gt;tool = TavilySearch(max_results = 2)&lt;/code&gt;，直接invoke，得到结果如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;query&quot;: &quot;LangGraph里面的node是什么？&quot;,
  &quot;follow_up_questions&quot;: null,
  &quot;answer&quot;: null,
  &quot;images&quot;: [],
  &quot;results&quot;: [
    {
      &quot;url&quot;: &quot;https://www.cnblogs.com/luzhanshi/articles/19141931&quot;,
      &quot;title&quot;: &quot;Ch.7 LangGraph底层原理与基础应用入门 - 博客园&quot;,
      &quot;content&quot;: &quot;接下来的步骤是向这个图中添加节点和边，完善和丰富图的内部执行逻辑。 2.3 Nodes. 在 LangGraph 中，节点是一个 python 函数（sync 或async ），接收&quot;,
      &quot;score&quot;: 0.99996924,
      &quot;raw_content&quot;: null
    },
    {
      &quot;url&quot;: &quot;http://www.bilibili.com/read/cv42850203/&quot;,
      &quot;title&quot;: &quot;LangGraphAgent开发实战- 哔哩哔哩&quot;,
      &quot;content&quot;: &quot;... 里面添加Node才能形成有向有环图. Node. Node是LangGraph的节点，每个节点代表一个函数或一个计算步骤。 你可以定义节点来执行特定任务，例如处理输入、做出决策或与外部&quot;,
      &quot;score&quot;: 0.99996495,
      &quot;raw_content&quot;: null
    }
  ],
  &quot;response_time&quot;: 1.07,
  &quot;request_id&quot;: &quot;13235394-9b4d-4a56-a08e-3c6f4024f03c&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，tool被invoke之后返回的是工具函数本身的返回值，我们在LangChain中，曾经采用的方法是将其包装为ToolMessage，再继续给模型推理。&lt;/p&gt;
&lt;h3&gt;(2) bind_tools()&lt;/h3&gt;
&lt;p&gt;而在LangChain中关于Model的介绍中，我们学习了给模型绑定工具的方法&lt;code&gt;bind_tools&lt;/code&gt;，我们可以直接给模型bind一个工具，加入到StateGraph中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated

from typing_extensions import TypedDict

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]

graph_builder = StateGraph(State)

# Modification: tell the LLM which tools it can call
# highlight-next-line
llm_with_tools = llm.bind_tools(tools)

def chatbot(state: State):
    return {&quot;messages&quot;: [llm_with_tools.invoke(state[&quot;messages&quot;])]}

graph_builder.add_node(&quot;chatbot&quot;, chatbot)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式是，默认由模型自己选择调用与否，在拿到的 AIMessage.tool_calls 中看到它要调用那些工具。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果纯用model.invoke()，通常只会拿到“要调用工具的意图”，最终产出AIMessage.tool_calls，这一步还没有真正工具执行。&lt;/li&gt;
&lt;li&gt;另外，模型也并非一定会阐述tool_calls。一般有这几种方法强制模型调用：
&lt;ul&gt;
&lt;li&gt;用模型支持的tool_choice强制模型阐述tool_calls&lt;/li&gt;
&lt;li&gt;用图结构兜底，比如如果tool_calls为空就重试、报错、或路由到自定义的节点&lt;/li&gt;
&lt;li&gt;提示词约束&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;产生了tool_calls以后，才能去往ToolNode进行正确的函数调用并返回结果！&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) ToolNode&lt;/h3&gt;
&lt;p&gt;我们在学习LangChain的时候，介绍Tools这一节，我们跳过了ToolNode的学习，现在我们重新进行学习：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool
from langgraph.prebuilt import ToolNode
from langgraph.graph import StateGraph, MessagesState, START, END

@tool
def search(query: str) -&amp;gt; str:
    &quot;&quot;&quot;Search for information.&quot;&quot;&quot;
    return f&quot;Results for: {query}&quot;

@tool
def calculator(expression: str) -&amp;gt; str:
    &quot;&quot;&quot;Evaluate a math expression.&quot;&quot;&quot;
    return str(eval(expression))

# Create the ToolNode with your tools
tool_node = ToolNode([search, calculator])

# Use in a graph
builder = StateGraph(MessagesState)
builder.add_node(&quot;tools&quot;, tool_node)
# ... add other nodes and edges
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到用法几乎和前面一样，只是节点用了&lt;code&gt;from langgraph.prebuilt import ToolNode&lt;/code&gt;里面的可调用类ToolNode。ToolNode还提供了错误验证处理机制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.prebuilt import ToolNode

# Default: catch invocation errors, re-raise execution errors
tool_node = ToolNode(tools)

# Catch all errors and return error message to LLM
tool_node = ToolNode(tools, handle_tool_errors=True)

# Custom error message
tool_node = ToolNode(tools, handle_tool_errors=&quot;Something went wrong, please try again.&quot;)

# Custom error handler
def handle_error(e: ValueError) -&amp;gt; str:
    return f&quot;Invalid input: {e}&quot;

tool_node = ToolNode(tools, handle_tool_errors=handle_error)

# Only catch specific exception types
tool_node = ToolNode(tools, handle_tool_errors=(ValueError, TypeError))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，我们知道，工具的调用，大多是需要符合某种条件的，&lt;code&gt;tool_condition&lt;/code&gt;就是专门用来根据大模型是否调用工具进行条件路由的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.graph import StateGraph, MessagesState, START, END

builder = StateGraph(MessagesState)
builder.add_node(&quot;llm&quot;, call_llm)
builder.add_node(&quot;tools&quot;, ToolNode(tools))

builder.add_edge(START, &quot;llm&quot;)
builder.add_conditional_edges(&quot;llm&quot;, tools_condition)  # Routes to &quot;tools&quot; or END
builder.add_edge(&quot;tools&quot;, &quot;llm&quot;)

graph = builder.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上例，就是一个标准的ReAct风格agent图了，我们用相同的方法打印出来就能看到：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fgraph.BoXu0TgK.png&amp;amp;w=216&amp;amp;h=249&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;add_conditional_edges的核心是“从哪个节点执行完后开始判断路由”，这个节点可以是llm，也可以是tools、普通函数节点，没有区别。他的语法是&lt;code&gt;add_conditional_edges(&quot;a&quot;, route, ...)&lt;/code&gt;，根据route返回动态跳转。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先执行源节点（比如 llm）。&lt;/li&gt;
&lt;li&gt;执行完后，LangGraph 在 Python 侧调用你的路由函数 route(state, ...)。&lt;/li&gt;
&lt;li&gt;路由函数返回下一个目标（节点名 / 多个节点 / END），图再继续执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们可以自己写这个route函数，上例是（tools_codition这个预构建好的工具选择，有tool_calls则路由到工具节点处）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Literal
from langgraph.graph import END

def route(state) -&amp;gt; Literal[&quot;tools&quot;, &quot;chat&quot;, END]:
    last = state[&quot;messages&quot;][-1]
    text = (last.content or &quot;&quot;).lower()

    if getattr(last, &quot;tool_calls&quot;, None):
        return &quot;tools&quot;
    if &quot;结束&quot; in text:
        return END
    return &quot;chat&quot;

builder.add_conditional_edges(&quot;llm&quot;, route)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以用映射表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def route(state):
    return state[&quot;mode&quot;]  # &quot;search&quot; / &quot;done&quot;

builder.add_conditional_edges(&quot;llm&quot;, route, {
    &quot;search&quot;: &quot;tools&quot;,
    &quot;done&quot;: END
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要确保有一条路径通往END就可以了，不然会死循环。&lt;/p&gt;
&lt;h2&gt;4. 添加记忆&lt;/h2&gt;
&lt;p&gt;作为一个Agent，除了使用工具以外，还必须要记得交互的上下文，从而获取连贯多轮对话的能力。LangGraph是通过持久性检查点解决了这个问题。&lt;/p&gt;
&lt;p&gt;具体而言，如果在编译图时提供一个checkpointer，并在调用图时提供一个thread_id，LangGraph 会在每一步之后自动保存状态。当使用相同的thread_id再次调用图时，图会加载其保存的状态，允许聊天机器人从上次中断的地方继续。&lt;/p&gt;
&lt;p&gt;检查点比简单的聊天记忆功能强大得多——它允许您随时保存和恢复复杂状态，用于错误恢复、人工干预工作流、时间旅行交互等。&lt;/p&gt;
&lt;h3&gt;(1) 多轮对话实现&lt;/h3&gt;
&lt;p&gt;我们在LangChain中提到，如果要为智能体添加线程级记忆，需要在创建时指定checkpoint，当时使用的是InMemorySaver。MemorySaver 和 InMemorySaver 在现在的 LangGraph 里本质没区别，新代码的InMemorySaver建议向后兼容。（以后可能会有不同的SqliteSaver或者PostgreSaver，到时候查文档）。&lt;/p&gt;
&lt;p&gt;我明明导入类创建实例，使用提供的检查点编译图，图在遍历每个节点时将对State设置检查点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.checkpoint.memory import MemorySaver

memory = MemorySaver()
graph = graph_builder.compile(checkpointer=memory)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意现在，需要选择一个线程作为对话的键，作为第二个参数提供：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}

user_input = &quot;Hi there! My name is Will.&quot;

# The config is the **second positional argument** to stream() or invoke()!
events = graph.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_input}]},
    config,
    stream_mode=&quot;values&quot;,
)
for event in events:
    event[&quot;messages&quot;][-1].pretty_print()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 检查State&lt;/h3&gt;
&lt;p&gt;我们可以在不同的线程中创建检查点，可是检查点中包含什么？要随时检查state，我们会使用&lt;code&gt;get_state(config)&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;snapshot = graph.get_state(config)
print(snapshot)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;得到&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;StateSnapshot&quot;,
  &quot;thread&quot;: {
    &quot;thread_id&quot;: &quot;1&quot;,
    &quot;checkpoint_ns&quot;: &quot;&quot;,
    &quot;checkpoint_id&quot;: &quot;1ef7d06e-93e0-6acc-8004-f2ac846575d2&quot;,
    &quot;parent_checkpoint_id&quot;: &quot;1ef7d06e-859f-6206-8003-e1bd3c264b8f&quot;
  },
  &quot;timeline&quot;: {
    &quot;created_at&quot;: &quot;2024-09-27T19:30:10.820758+00:00&quot;,
    &quot;step&quot;: 4,
    &quot;source&quot;: &quot;loop&quot;
  },
  &quot;messages&quot;: [
    {
      &quot;role&quot;: &quot;human&quot;,
      &quot;content&quot;: &quot;Hi there! My name is Will.&quot;
    },
    {
      &quot;role&quot;: &quot;ai&quot;,
      &quot;model&quot;: &quot;claude-3-5-sonnet-20240620&quot;,
      &quot;content&quot;: &quot;Hello Will! It&apos;s nice to meet you...&quot;,
      &quot;usage&quot;: {
        &quot;input_tokens&quot;: 405,
        &quot;output_tokens&quot;: 32,
        &quot;total_tokens&quot;: 437
      }
    },
    {
      &quot;role&quot;: &quot;human&quot;,
      &quot;content&quot;: &quot;Remember my name?&quot;
    },
    {
      &quot;role&quot;: &quot;ai&quot;,
      &quot;model&quot;: &quot;claude-3-5-sonnet-20240620&quot;,
      &quot;content&quot;: &quot;Of course, I remember your name, Will...&quot;,
      &quot;usage&quot;: {
        &quot;input_tokens&quot;: 444,
        &quot;output_tokens&quot;: 58,
        &quot;total_tokens&quot;: 502
      }
    }
  ],
  &quot;state&quot;: {
    &quot;next&quot;: [],
    &quot;tasks&quot;: [],
    &quot;parents&quot;: {}
  },
  &quot;last_write&quot;: {
    &quot;node&quot;: &quot;chatbot&quot;,
    &quot;field&quot;: &quot;messages&quot;,
    &quot;value&quot;: &quot;最后一条 AI 回复（确认记住名字 Will）&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;感到眼熟？那就对了，这就其中的message字典对应的就是AIMessage，相对的，多了type、thread、timeline等。&lt;/p&gt;
&lt;h2&gt;5. human-in-loop&lt;/h2&gt;
&lt;p&gt;Agent可能不完全可靠，有时候需要依赖人工输入才能完成任务。这就需要我们添加 human_assistance 到流程中。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from typing import Annotated
from typing_extensions import TypedDict

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_tavily import TavilySearch

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.types import Command, interrupt

BASE_DIR = os.path.dirname(__file__)
load_dotenv(os.path.join(BASE_DIR, &quot;.env&quot;))

llm = init_chat_model(
    &quot;openai:gpt-4o-mini&quot;,
    base_url=os.getenv(&quot;QIHANG_BASE_URL&quot;),
    api_key=os.getenv(&quot;QIHANG_API&quot;),
)

class State(TypedDict):
    messages: Annotated[list, add_messages]

graph_builder = StateGraph(State)

@tool
def human_assistance(query: str) -&amp;gt; str:
    &quot;&quot;&quot;Request assistance from a human.&quot;&quot;&quot;
    human_response = interrupt({&quot;query&quot;: query})
    return human_response[&quot;data&quot;]

tavily_tool = TavilySearch(max_results=2)
tools = [tavily_tool, human_assistance]

llm_with_tools = llm.bind_tools(tools)

def chatbot(state: State):
    message = llm_with_tools.invoke(state[&quot;messages&quot;])
    # 避免恢复后重复并行工具调用
    assert len(message.tool_calls) &amp;lt;= 1
    return {&quot;messages&quot;: [message]}

graph_builder.add_node(&quot;chatbot&quot;, chatbot)
graph_builder.add_node(&quot;tools&quot;, ToolNode(tools=tools))

graph_builder.add_conditional_edges(&quot;chatbot&quot;, tools_condition)
graph_builder.add_edge(&quot;tools&quot;, &quot;chatbot&quot;)
graph_builder.add_edge(START, &quot;chatbot&quot;)

memory = MemorySaver()
graph = graph_builder.compile(checkpointer=memory)

if __name__ == &quot;__main__&quot;:
    config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}
    user_input = input(&quot;User: &quot;).strip() or (
        &quot;I need some expert guidance for building an AI agent. &quot;
        &quot;Could you request assistance for me?&quot;
    )

    # 1) 首次运行：可能会在 human_assistance 处触发 interrupt
    events = graph.stream(
        {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_input}]},
        config,
        stream_mode=&quot;values&quot;,
    )
    interrupted = False
    for event in events:
        if &quot;messages&quot; in event:
            event[&quot;messages&quot;][-1].pretty_print()
        if &quot;__interrupt__&quot; in event:
            interrupted = True

    # 2) 如果触发中断，真实读取人工输入并恢复
    if interrupted:
        human_response = input(&quot;Human response: &quot;).strip()
        if human_response:
            human_command = Command(resume={&quot;data&quot;: human_response})
            events = graph.stream(human_command, config, stream_mode=&quot;values&quot;)
            for event in events:
                if &quot;messages&quot; in event:
                    event[&quot;messages&quot;][-1].pretty_print()

&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;首次 &lt;code&gt;graph.stream(...)&lt;/code&gt; 运行到 &lt;code&gt;interrupt(...)&lt;/code&gt; 时暂停。&lt;/li&gt;
&lt;li&gt;终端真实输入 &lt;code&gt;Human response&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;Command(resume={&quot;data&quot;: human_response})&lt;/code&gt; 恢复执行。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;另外，这里仍然是标准 ReAct 图：&lt;code&gt;chatbot -&amp;gt; tools -&amp;gt; chatbot / END&lt;/code&gt;。&lt;code&gt;llm.bind_tools(tools)&lt;/code&gt; 负责让模型知道可用工具，&lt;code&gt;ToolNode + tools_condition&lt;/code&gt; 负责真正执行工具。&lt;/p&gt;
&lt;h2&gt;6. 自定义State&lt;/h2&gt;
&lt;h3&gt;(1) 自己添加键&lt;/h3&gt;
&lt;p&gt;添加到State里面的信息可以被下游节点以及图的持久层访问。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class State(TypedDict):
    messages: Annotated[list, add_messages]
    name: str
    birthday: str
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 在工具内部更新状态&lt;/h3&gt;
&lt;p&gt;在 human_assistance 工具内部填充状态键。这允许人工在信息存储到状态之前对其进行审查。使用 Command 从工具内部发出状态更新。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain_core.messages import ToolMessage
from langchain_core.tools import InjectedToolCallId, tool

from langgraph.types import Command, interrupt

@tool
# Note that because we are generating a ToolMessage for a state update, we
# generally require the ID of the corresponding tool call. We can use
# LangChain&apos;s InjectedToolCallId to signal that this argument should not
# be revealed to the model in the tool&apos;s schema.
def human_assistance(
    name: str, birthday: str, tool_call_id: Annotated[str, InjectedToolCallId]
) -&amp;gt; str:
    &quot;&quot;&quot;Request assistance from a human.&quot;&quot;&quot;
    human_response = interrupt(
        {
            &quot;question&quot;: &quot;Is this correct?&quot;,
            &quot;name&quot;: name,
            &quot;birthday&quot;: birthday,
        },
    )
    # If the information is correct, update the state as-is.
    if human_response.get(&quot;correct&quot;, &quot;&quot;).lower().startswith(&quot;y&quot;):
        verified_name = name
        verified_birthday = birthday
        response = &quot;Correct&quot;
    # Otherwise, receive information from the human reviewer.
    else:
        verified_name = human_response.get(&quot;name&quot;, name)
        verified_birthday = human_response.get(&quot;birthday&quot;, birthday)
        response = f&quot;Made a correction: {human_response}&quot;

    # This time we explicitly update the state with a ToolMessage inside
    # the tool.
    state_update = {
        &quot;name&quot;: verified_name,
        &quot;birthday&quot;: verified_birthday,
        &quot;messages&quot;: [ToolMessage(response, tool_call_id=tool_call_id)],
    }
    # We return a Command object in the tool to update our state.
    return Command(update=state_update)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还有提醒聊天机器人、添加人工协助、手动更新状态、查看新值等比较自然的用法。&lt;/p&gt;
&lt;h2&gt;7. time-travel（时间旅行）&lt;/h2&gt;
&lt;p&gt;命运石之门来咯）&lt;/p&gt;
&lt;p&gt;这一节的核心不是“回放聊天记录”，而是“从某个历史检查点重新继续运行图”。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前置条件：图必须 &lt;code&gt;compile(checkpointer=...)&lt;/code&gt;，并且调用时使用同一个 &lt;code&gt;thread_id&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;关键能力有两种：
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Replay&lt;/code&gt;（重播）：从历史 checkpoint 继续跑，后续节点会重新执行。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fork&lt;/code&gt;（分叉）：在历史 checkpoint 上改一部分状态，再沿新分支继续跑。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) 回看完整历史：&lt;code&gt;get_state_history&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;先看线程里有哪些 checkpoint（按时间倒序）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;to_replay = None
for state in graph.get_state_history(config):
    print(&quot;Num Messages:&quot;, len(state.values[&quot;messages&quot;]), &quot;Next:&quot;, state.next)
    # 例子：挑一个中间状态（这里只是演示，实际可以换成别的条件）
    if len(state.values[&quot;messages&quot;]) == 6:
        to_replay = state
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最重要的两个字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;state.next&lt;/code&gt;：从这个 checkpoint 恢复后，下一个要执行的节点是谁。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state.config[&quot;configurable&quot;][&quot;checkpoint_id&quot;]&lt;/code&gt;：这个历史点的唯一标识。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(2) 从某个历史点恢复执行（Replay）&lt;/h3&gt;
&lt;p&gt;教程里最关键的一行就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for event in graph.stream(None, to_replay.config, stream_mode=&quot;values&quot;):
    if &quot;messages&quot; in event:
        event[&quot;messages&quot;][-1].pretty_print()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这里传 &lt;code&gt;None&lt;/code&gt; 作为输入，表示“不提供新输入，直接从 checkpoint 接着跑”。&lt;/li&gt;
&lt;li&gt;传 &lt;code&gt;to_replay.config&lt;/code&gt;，就是告诉 LangGraph“从这个历史点恢复”。&lt;/li&gt;
&lt;li&gt;它会从 &lt;code&gt;state.next&lt;/code&gt; 对应节点继续执行，所以后续工具调用/LLM 调用会重新发生。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) 一句话区分：Replay vs Fork&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Replay&lt;/code&gt;：用旧 checkpoint 原样继续跑。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fork&lt;/code&gt;：先改状态再继续跑（更像“平行世界”）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如（进阶）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 在历史点上改状态，生成新分支
fork_config = graph.update_state(
    to_replay.config,
    values={&quot;messages&quot;: [(&quot;user&quot;, &quot;换个方向继续&quot;)]},
)

# 从新分支继续执行
result = graph.invoke(None, fork_config)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以上，算是简单入门了LangGraph，接下来，我们直接顺着官方文档，开始一个能力一个能力查看。&lt;/p&gt;
</content:encoded></item><item><title>微调入门：为什么需要微调、学习范式与参数更新范围</title><link>https://owen571.top/posts/study/fine-tuning/01-%E5%BE%AE%E8%B0%83%E5%85%A5%E9%97%A8-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E5%BE%AE%E8%B0%83%E4%B8%8E%E5%B8%B8%E8%A7%81%E8%B7%AF%E7%BA%BF/</link><guid isPermaLink="true">https://owen571.top/posts/study/fine-tuning/01-%E5%BE%AE%E8%B0%83%E5%85%A5%E9%97%A8-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E5%BE%AE%E8%B0%83%E4%B8%8E%E5%B8%B8%E8%A7%81%E8%B7%AF%E7%BA%BF/</guid><description>从最基础的问题开始：什么场景下需要微调，微调的一般流程是什么，以及全参数微调、冻结微调、PEFT 分别在解决什么问题。</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇先不急着进工具和参数，而是先把“为什么要微调”这件事想清楚。只有先搞清楚目标，后面看 LoRA、数据集和训练参数时才不会只剩操作步骤。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一、为什么要微调&lt;/h1&gt;
&lt;p&gt;有时候，即使采用本地部署加知识库，也不能很好满足某些场景。因为常见大模型虽然基于海量数据训练，具备广泛的通用能力，但在特定领域、特定任务和特定输出风格上，往往还不够稳定。&lt;/p&gt;
&lt;p&gt;通常会希望模型额外具备下面几类能力：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;领域专业化：行业黑话、专业术语、专门知识要更稳地理解。&lt;/li&gt;
&lt;li&gt;任务适配：希望输出风格、格式和结构更固定。&lt;/li&gt;
&lt;li&gt;能力纠偏：在冷门场景或长尾问题上减少跑偏。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;从问题拆解的角度看，长文本、知识库和微调并不是互斥关系，而是三种不同的优化方向。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比维度&lt;/th&gt;
&lt;th&gt;长文本处理&lt;/th&gt;
&lt;th&gt;知识库&lt;/th&gt;
&lt;th&gt;微调&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心目标&lt;/td&gt;
&lt;td&gt;理解和生成长篇内容&lt;/td&gt;
&lt;td&gt;提供背景知识，增强回答能力&lt;/td&gt;
&lt;td&gt;优化模型在特定任务或领域的表现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;优点&lt;/td&gt;
&lt;td&gt;连贯性强，适合复杂任务&lt;/td&gt;
&lt;td&gt;灵活性高，可随时更新&lt;/td&gt;
&lt;td&gt;性能提升，定制化强&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;缺点&lt;/td&gt;
&lt;td&gt;资源消耗大，上下文限制明显&lt;/td&gt;
&lt;td&gt;依赖检索，实时性要求高&lt;/td&gt;
&lt;td&gt;需要标注数据，训练成本高&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;适用场景&lt;/td&gt;
&lt;td&gt;写作助手、长文理解&lt;/td&gt;
&lt;td&gt;智能客服、问答系统&lt;/td&gt;
&lt;td&gt;专业领域、固定任务、风格定制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;实时性&lt;/td&gt;
&lt;td&gt;静态，依赖输入&lt;/td&gt;
&lt;td&gt;动态，知识库可更新&lt;/td&gt;
&lt;td&gt;静态，训练后固定&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;所以微调更像是在回答一个问题：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;当模型不仅要“知道”，还要“稳定地按某种方式做”时，是不是该把这种能力真正写进参数里。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;二、一个微调的大致流程&lt;/h1&gt;
&lt;p&gt;微调的一般过程可以概括为 7 步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;选定预训练模型。&lt;/li&gt;
&lt;li&gt;准备并加载微调数据集。&lt;/li&gt;
&lt;li&gt;先准备一组固定问题，用于微调前后对比。&lt;/li&gt;
&lt;li&gt;设定训练超参数。&lt;/li&gt;
&lt;li&gt;进行训练。&lt;/li&gt;
&lt;li&gt;评估训练后的回答效果。&lt;/li&gt;
&lt;li&gt;不满意就继续调数据、调参数、重新训练。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这条流程看起来很简单，但真正最耗时间的其实是中间三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据集怎么组织&lt;/li&gt;
&lt;li&gt;参数怎么选&lt;/li&gt;
&lt;li&gt;结果怎么解释&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后面几篇会分别把这三块拆开。&lt;/p&gt;
&lt;h1&gt;三、微调可以怎么分类&lt;/h1&gt;
&lt;p&gt;微调可以从很多维度分类。这里先保留原笔记里的三条主线：学习范式、参数更新范围、任务类型。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250829013502.png&quot; alt=&quot;微调分类总览&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250828164633.png&quot; alt=&quot;学习范式示意&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;1. 按学习范式&lt;/h2&gt;
&lt;h3&gt;（1）预训练&lt;/h3&gt;
&lt;p&gt;通常不是微调者自己做的阶段，而是通用大模型已经完成的那一轮大规模学习。预训练模型先学到通用语言规律、图像规律或多模态对齐能力，微调是在这个基础上继续塑形。&lt;/p&gt;
&lt;h3&gt;（2）监督微调（SFT）&lt;/h3&gt;
&lt;p&gt;监督微调是最常见、也最容易上手的一类微调。它使用带标签的任务数据继续训练模型，让模型更会做某个特定任务。&lt;/p&gt;
&lt;p&gt;比如做英译中，只要数据集中提供英文输入和中文输出，模型就会被拉向这个映射关系。&lt;/p&gt;
&lt;h3&gt;（3）无监督微调&lt;/h3&gt;
&lt;p&gt;无监督微调使用没有标签但与任务相关的数据继续训练模型。它依赖的是数据本身的分布规律，而不是人工标注。&lt;/p&gt;
&lt;p&gt;常见的无监督学习目标有两类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自回归：根据前面的 token 预测下一个 token，代表模型是 GPT、LLaMA、Claude 等。&lt;/li&gt;
&lt;li&gt;自编码：根据上下文预测被 mask 的 token，代表模型是 BERT、RoBERTa、BART 等。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;（4）自监督微调&lt;/h3&gt;
&lt;p&gt;自监督其实是无监督学习里最重要的一支。它不是单纯“没有标签”，而是通过数据本身构造监督信号，相当于让模型自己给自己出题。&lt;/p&gt;
&lt;p&gt;GPT 的自回归、BERT 的掩码预测都属于自监督范式。&lt;/p&gt;
&lt;h3&gt;（5）强化学习微调&lt;/h3&gt;
&lt;p&gt;这一类和前面差别很大，它不再只是“对着标准答案学”，而是通过奖励信号优化输出，让结果更符合人类偏好。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;监督微调&lt;/th&gt;
&lt;th&gt;无监督 / 自监督微调&lt;/th&gt;
&lt;th&gt;强化学习微调&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心数据&lt;/td&gt;
&lt;td&gt;带标签任务数据&lt;/td&gt;
&lt;td&gt;无标签任务数据&lt;/td&gt;
&lt;td&gt;人类偏好 / 奖励信号&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主要目标&lt;/td&gt;
&lt;td&gt;特定任务性能&lt;/td&gt;
&lt;td&gt;领域适应&lt;/td&gt;
&lt;td&gt;对齐人类偏好&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;典型技术&lt;/td&gt;
&lt;td&gt;交叉熵损失&lt;/td&gt;
&lt;td&gt;自回归、掩码学习、对比学习&lt;/td&gt;
&lt;td&gt;PPO、DPO 等&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据成本&lt;/td&gt;
&lt;td&gt;高&lt;/td&gt;
&lt;td&gt;低&lt;/td&gt;
&lt;td&gt;很高&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;流程复杂度&lt;/td&gt;
&lt;td&gt;低&lt;/td&gt;
&lt;td&gt;中&lt;/td&gt;
&lt;td&gt;很高&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;如果目标是明确分类任务，多数情况下 SFT 就足够了。&lt;/p&gt;
&lt;h2&gt;2. 按参数更新范围&lt;/h2&gt;
&lt;p&gt;这是微调里最现实的一组分类，因为它直接决定硬件要求和训练成本。&lt;/p&gt;
&lt;h3&gt;（1）全参数微调&lt;/h3&gt;
&lt;p&gt;最直接的方法：加载全部权重，在下游数据上更新所有参数。&lt;/p&gt;
&lt;p&gt;优点是理论潜力最大；缺点是显存、算力和过拟合风险都最高。&lt;/p&gt;
&lt;h3&gt;（2）冻结微调&lt;/h3&gt;
&lt;p&gt;冻结大部分预训练层，只替换或解冻最后几层做训练。思路是：底层特征往往更通用，顶层特征更贴近任务。&lt;/p&gt;
&lt;h3&gt;（3）参数高效微调（PEFT）&lt;/h3&gt;
&lt;p&gt;PEFT 的目标很明确：&lt;strong&gt;只训练极少量参数，但尽可能保留接近全参数微调的效果。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它大体又可以分成几类：&lt;/p&gt;
&lt;h4&gt;① 适配器类&lt;/h4&gt;
&lt;p&gt;在原模型结构里插入一些小模块，训练这些新模块，冻结原始参数。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Adapter Tuning&lt;/li&gt;
&lt;li&gt;Parallel Adapter&lt;/li&gt;
&lt;li&gt;LoRA&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250829014635.png&quot; alt=&quot;Adapter Tuning&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250829013718.png&quot; alt=&quot;LoRA 旁路结构&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;Adapter Tuning&lt;/th&gt;
&lt;th&gt;Parallel Adapter&lt;/th&gt;
&lt;th&gt;LoRA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心思想&lt;/td&gt;
&lt;td&gt;串行插入 Adapter 模块&lt;/td&gt;
&lt;td&gt;与原模块并行放置 Adapter&lt;/td&gt;
&lt;td&gt;用低秩矩阵近似权重更新&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;推理速度&lt;/td&gt;
&lt;td&gt;会引入串行延迟&lt;/td&gt;
&lt;td&gt;延迟较低&lt;/td&gt;
&lt;td&gt;几乎无推理延迟&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主要优点&lt;/td&gt;
&lt;td&gt;参数效率高&lt;/td&gt;
&lt;td&gt;结构更高效&lt;/td&gt;
&lt;td&gt;参数少、部署方便、最主流&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主要缺点&lt;/td&gt;
&lt;td&gt;推理变慢&lt;/td&gt;
&lt;td&gt;效果依赖实现&lt;/td&gt;
&lt;td&gt;低秩假设并非总能完全覆盖复杂变化&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;② 提示工程类&lt;/h4&gt;
&lt;p&gt;通过加入可训练的软提示，让冻结模型朝任务方向偏移。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prefix Tuning&lt;/li&gt;
&lt;li&gt;Prompt Tuning&lt;/li&gt;
&lt;li&gt;P-Tuning&lt;/li&gt;
&lt;li&gt;P-Tuning v2&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250829003305.png&quot; alt=&quot;Prefix Tuning&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250829012116.png&quot; alt=&quot;Prompt Tuning&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250829012646.png&quot; alt=&quot;P-Tuning&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250829013207.png&quot; alt=&quot;P-Tuning v2&quot; /&gt;&lt;/p&gt;
&lt;h4&gt;③ 低秩适配类&lt;/h4&gt;
&lt;p&gt;核心思想是利用低秩矩阵模拟权重更新。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LoRA&lt;/li&gt;
&lt;li&gt;QLoRA&lt;/li&gt;
&lt;li&gt;Delta-LoRA&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;④ 稀疏方法类&lt;/h4&gt;
&lt;p&gt;核心思想是只训练原模型参数中的一个稀疏子集。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;BitFit&lt;/li&gt;
&lt;li&gt;Fish Mask&lt;/li&gt;
&lt;li&gt;Intrinsic SAID&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 按任务类型&lt;/h2&gt;
&lt;p&gt;这条线更贴近业务目标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;指令微调&lt;/li&gt;
&lt;li&gt;领域适应微调&lt;/li&gt;
&lt;li&gt;风格迁移微调&lt;/li&gt;
&lt;li&gt;多模态微调&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果目标是“让 Qwen2.5-VL 更懂某类图像并完成固定分类任务”，那它显然属于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多模态微调&lt;/li&gt;
&lt;li&gt;监督微调&lt;/li&gt;
&lt;li&gt;参数高效微调里的 LoRA / QLoRA 路线&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;四、先留下一个实践判断&lt;/h1&gt;
&lt;p&gt;这组笔记最后要做的是多模态图像分类任务，所以最终选择监督微调，并优先考虑 LoRA 这类参数高效方法。这不是因为它“理论最高级”，而是因为它在硬件条件、数据规模和任务目标之间最平衡。&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 核心能力 01：Persistence、线程、检查点与 Store</title><link>https://owen571.top/posts/study/langgraph/02-langgraph-%E6%8C%81%E4%B9%85%E5%8C%96-%E7%BA%BF%E7%A8%8B%E6%A3%80%E6%9F%A5%E7%82%B9%E4%B8%8E-store/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/02-langgraph-%E6%8C%81%E4%B9%85%E5%8C%96-%E7%BA%BF%E7%A8%8B%E6%A3%80%E6%9F%A5%E7%82%B9%E4%B8%8E-store/</guid><description>把 LangGraph 的持久化层拆开看：thread、checkpoint、state history、replay、update_state 和 Store 分别解决什么问题。</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Persistence&lt;/code&gt; 是 LangGraph 真正和普通“函数编排”拉开差距的地方。线程、检查点、状态历史和 Store 是一整套协作机制，不是几个分散功能。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;LangGraph 内置持久化层，可将图状态以检查点形式保存。当你使用检查点器编译图时，图状态的快照会在执行的每一步被保存，并按线程进行组织。这支持人机协同工作流、对话记忆、回溯调试以及容错执行。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-1.XZ_gyUMT.png&amp;amp;w=2316&amp;amp;h=748&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;持久化，对于Human-in-the-loop、Memory、Time travel、Fault-tolerance、Pending writes都是很有用的。&lt;/p&gt;
&lt;h2&gt;2. 线程 (Threads)&lt;/h2&gt;
&lt;p&gt;线程是检查点保存器为每个保存的检查点分配的唯一标识（ID）或线程标识符。它包含一系列运行的累积状态。执行一次运行时，助手底层图的状态将持久化到该线程中。
在使用检查点保存器调用图时，你必须在配置的configurable部分中指定一个 thread_id：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可获取线程的当前状态与历史状态。若要持久化状态，必须在执行运行任务前创建线程。LangSmith API 提供多个接口用于创建和管理线程及线程状态。&lt;/p&gt;
&lt;p&gt;检查点存储器以thread_id作为存储和读取检查点的主键。若无此标识，检查点存储器将无法保存状态，也无法在中断后恢复执行，因为它需要通过thread_id加载已保存的状态。&lt;/p&gt;
&lt;h2&gt;3. 检查点 (Checkpoints)&lt;/h2&gt;
&lt;p&gt;线程在特定时间点的状态(State)称为检查点。检查点是在每个超步保存的图状态快照，由 &lt;code&gt;StateSnapshot&lt;/code&gt; 对象表示。根据官方文档，&lt;code&gt;StateSnapshot&lt;/code&gt; 字段如下：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;描述&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;values&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;该检查点对应的状态通道值。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;next&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tuple[str, ...]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;下一步将要执行的节点名；为空 &lt;code&gt;()&lt;/code&gt; 表示图已完成。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前检查点配置，包含 &lt;code&gt;thread_id&lt;/code&gt;、&lt;code&gt;checkpoint_ns&lt;/code&gt;、&lt;code&gt;checkpoint_id&lt;/code&gt;。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;执行元数据，包含 &lt;code&gt;source&lt;/code&gt;（&lt;code&gt;&quot;input&quot;&lt;/code&gt;、&lt;code&gt;&quot;loop&quot;&lt;/code&gt;、&lt;code&gt;&quot;update&quot;&lt;/code&gt;）、&lt;code&gt;writes&lt;/code&gt;（节点写入内容）、&lt;code&gt;step&lt;/code&gt;（超步计数）。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;str&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;检查点创建时间（ISO 8601）。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;parent_config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dict | None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;上一个检查点配置；首个检查点为 &lt;code&gt;None&lt;/code&gt;。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tasks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tuple[PregelTask, ...]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前步骤任务集合。每个任务含 &lt;code&gt;id&lt;/code&gt;、&lt;code&gt;name&lt;/code&gt;、&lt;code&gt;error&lt;/code&gt;、&lt;code&gt;interrupts&lt;/code&gt;，并在 &lt;code&gt;subgraphs=True&lt;/code&gt; 时可含 &lt;code&gt;state&lt;/code&gt;（子图快照）。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;实战读法（你调试时最常看）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;看 &lt;code&gt;next&lt;/code&gt;：确认接下来会跑哪个节点，&lt;code&gt;()&lt;/code&gt; 就是已结束。&lt;/li&gt;
&lt;li&gt;看 &lt;code&gt;metadata[&quot;source&quot;]&lt;/code&gt;：区分本检查点来源于输入(&lt;code&gt;input&lt;/code&gt;)、正常循环执行(&lt;code&gt;loop&lt;/code&gt;)还是手动更新状态(&lt;code&gt;update&lt;/code&gt;)。&lt;/li&gt;
&lt;li&gt;看 &lt;code&gt;metadata[&quot;writes&quot;]&lt;/code&gt;：快速定位“这个检查点是谁写出来的”。&lt;/li&gt;
&lt;li&gt;看 &lt;code&gt;tasks&lt;/code&gt;：排查中断(&lt;code&gt;interrupts&lt;/code&gt;)和错误(&lt;code&gt;error&lt;/code&gt;)时最关键。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LangGraph 会在每个超步(Super-steps)边界创建检查点。超步是图的一次 “节拍”，在该节拍中，所有被调度到该步骤的节点都会执行（可能并行执行）。对于像START -&amp;gt; A -&amp;gt; B -&amp;gt; END这样的顺序图，输入、节点 A 和节点 B 各对应一个独立的超步 —— 每个超步完成后都会生成一个检查点。理解超步边界对于时间回溯至关重要，因为你只能从检查点（即超步边界）恢复执行。&lt;/p&gt;
&lt;p&gt;检查点会被持久化存储，可用于在后续时间恢复线程状态。
我们来看一下当一个简单图按如下方式调用时，会保存哪些检查点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.runnables import RunnableConfig
from typing import Annotated
from typing_extensions import TypedDict
from operator import add

class State(TypedDict):
    foo: str
    bar: Annotated[list[str], add]

def node_a(state: State):
    return {&quot;foo&quot;: &quot;a&quot;, &quot;bar&quot;: [&quot;a&quot;]}

def node_b(state: State):
    return {&quot;foo&quot;: &quot;b&quot;, &quot;bar&quot;: [&quot;b&quot;]}


workflow = StateGraph(State)
workflow.add_node(node_a)
workflow.add_node(node_b)
workflow.add_edge(START, &quot;node_a&quot;)
workflow.add_edge(&quot;node_a&quot;, &quot;node_b&quot;)
workflow.add_edge(&quot;node_b&quot;, END)

checkpointer = InMemorySaver()
graph = workflow.compile(checkpointer=checkpointer)

config: RunnableConfig = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}
graph.invoke({&quot;foo&quot;: &quot;&quot;, &quot;bar&quot;:[]}, config)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行流程图后，我们预期会看到恰好4个检查点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;空检查点，下一个待执行节点为START&lt;/li&gt;
&lt;li&gt;包含用户输入{&apos;foo&apos;: &apos;&apos;, &apos;bar&apos;: []}且下一个待执行节点为node_a的检查点&lt;/li&gt;
&lt;li&gt;包含node_a输出结果{&apos;foo&apos;: &apos;a&apos;, &apos;bar&apos;: [&apos;a&apos;]}且下一个待执行节点为node_b的检查点&lt;/li&gt;
&lt;li&gt;包含node_b输出结果{&apos;foo&apos;: &apos;b&apos;, &apos;bar&apos;: [&apos;a&apos;, &apos;b&apos;]}且无后续待执行节点的检查点&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;checkpoint_ns&lt;/code&gt;（检查点命名空间）用来区分当前检查点属于主图还是某个子图：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;空字符串 &lt;code&gt;&quot;&quot;&lt;/code&gt;：属于最外层根图（parent graph）。&lt;/li&gt;
&lt;li&gt;节点名:uuid：属于该节点调用的子图。&lt;/li&gt;
&lt;li&gt;嵌套子图用 &lt;code&gt;|&lt;/code&gt; 连接：如 &lt;code&gt;outer_node:uuid|inner_node:uuid&lt;/code&gt;，表示外层子图里的内层子图。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;作用：让 LangGraph 知道状态属于哪一层图，避免多层嵌套时状态混乱。&lt;/p&gt;
&lt;h2&gt;4. 状态获取&lt;/h2&gt;
&lt;p&gt;与已保存的图状态交互时，你必须指定一个线程标识符。你可以通过调用graph.get_state(config)查看图的最新状态。该调用会返回一个StateSnapshot对象，对应配置中提供的线程 ID 所关联的最新检查点；若提供了检查点 ID，则返回该线程对应检查点 ID 的检查点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# get the latest state snapshot
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}
graph.get_state(config)

# get a state snapshot for a specific checkpoint_id
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;, &quot;checkpoint_id&quot;: &quot;1ef663ba-28fe-6528-8002-5a559208592c&quot;}}
graph.get_state(config)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以通过调用graph.get_state_history(config)获取指定线程的完整图执行历史。该方法会返回与配置中提供的线程 ID 相关联的 &lt;code&gt;StateSnapshot&lt;/code&gt; 列表。这个列表可按“最新在前”来理解（最常用）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}
list(graph.get_state_history(config))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以像官方示例一样按条件筛选特定检查点（非常实用）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;history = list(graph.get_state_history(config))

# 找到“即将执行 node_b”之前的检查点
before_node_b = next(s for s in history if s.next == (&quot;node_b&quot;,))

# 按 step 查找
step_2 = next(s for s in history if s.metadata[&quot;step&quot;] == 2)

# 找出所有由 update_state 产生的检查点（分叉点）
forks = [s for s in history if s.metadata[&quot;source&quot;] == &quot;update&quot;]

# 找到发生中断的检查点
interrupted = next(
    s for s in history
    if s.tasks and any(t.interrupts for t in s.tasks)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果会像这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
    StateSnapshot(
        values={&apos;foo&apos;: &apos;b&apos;, &apos;bar&apos;: [&apos;a&apos;, &apos;b&apos;]},
        next=(),
        config={&apos;configurable&apos;: {&apos;thread_id&apos;: &apos;1&apos;, &apos;checkpoint_ns&apos;: &apos;&apos;, &apos;checkpoint_id&apos;: &apos;1ef663ba-28fe-6528-8002-5a559208592c&apos;}},
        metadata={&apos;source&apos;: &apos;loop&apos;, &apos;writes&apos;: {&apos;node_b&apos;: {&apos;foo&apos;: &apos;b&apos;, &apos;bar&apos;: [&apos;b&apos;]}}, &apos;step&apos;: 2},
        created_at=&apos;2024-08-29T19:19:38.821749+00:00&apos;,
        parent_config={&apos;configurable&apos;: {&apos;thread_id&apos;: &apos;1&apos;, &apos;checkpoint_ns&apos;: &apos;&apos;, &apos;checkpoint_id&apos;: &apos;1ef663ba-28f9-6ec4-8001-31981c2c39f8&apos;}},
        tasks=(),
    ),
    StateSnapshot(
        values={&apos;foo&apos;: &apos;a&apos;, &apos;bar&apos;: [&apos;a&apos;]},
        next=(&apos;node_b&apos;,),
        config={&apos;configurable&apos;: {&apos;thread_id&apos;: &apos;1&apos;, &apos;checkpoint_ns&apos;: &apos;&apos;, &apos;checkpoint_id&apos;: &apos;1ef663ba-28f9-6ec4-8001-31981c2c39f8&apos;}},
        metadata={&apos;source&apos;: &apos;loop&apos;, &apos;writes&apos;: {&apos;node_a&apos;: {&apos;foo&apos;: &apos;a&apos;, &apos;bar&apos;: [&apos;a&apos;]}}, &apos;step&apos;: 1},
        created_at=&apos;2024-08-29T19:19:38.819946+00:00&apos;,
        parent_config={&apos;configurable&apos;: {&apos;thread_id&apos;: &apos;1&apos;, &apos;checkpoint_ns&apos;: &apos;&apos;, &apos;checkpoint_id&apos;: &apos;1ef663ba-28f4-6b4a-8000-ca575a13d36a&apos;}},
        tasks=(PregelTask(id=&apos;6fb7314f-f114-5413-a1f3-d37dfe98ff44&apos;, name=&apos;node_b&apos;, error=None, interrupts=()),),
    ),
    StateSnapshot(
        values={&apos;foo&apos;: &apos;&apos;, &apos;bar&apos;: []},
        next=(&apos;node_a&apos;,),
        config={&apos;configurable&apos;: {&apos;thread_id&apos;: &apos;1&apos;, &apos;checkpoint_ns&apos;: &apos;&apos;, &apos;checkpoint_id&apos;: &apos;1ef663ba-28f4-6b4a-8000-ca575a13d36a&apos;}},
        metadata={&apos;source&apos;: &apos;loop&apos;, &apos;writes&apos;: None, &apos;step&apos;: 0},
        created_at=&apos;2024-08-29T19:19:38.817813+00:00&apos;,
        parent_config={&apos;configurable&apos;: {&apos;thread_id&apos;: &apos;1&apos;, &apos;checkpoint_ns&apos;: &apos;&apos;, &apos;checkpoint_id&apos;: &apos;1ef663ba-28f0-6c66-bfff-6723431e8481&apos;}},
        tasks=(PregelTask(id=&apos;f1b14528-5ee5-579c-949b-23ef9bfbed58&apos;, name=&apos;node_a&apos;, error=None, interrupts=()),),
    ),
    StateSnapshot(
        values={&apos;bar&apos;: []},
        next=(&apos;__start__&apos;,),
        config={&apos;configurable&apos;: {&apos;thread_id&apos;: &apos;1&apos;, &apos;checkpoint_ns&apos;: &apos;&apos;, &apos;checkpoint_id&apos;: &apos;1ef663ba-28f0-6c66-bfff-6723431e8481&apos;}},
        metadata={&apos;source&apos;: &apos;input&apos;, &apos;writes&apos;: {&apos;foo&apos;: &apos;&apos;}, &apos;step&apos;: -1},
        created_at=&apos;2024-08-29T19:19:38.816205+00:00&apos;,
        parent_config=None,
        tasks=(PregelTask(id=&apos;6d27aa2e-d72b-5504-a36f-8620e54a76dd&apos;, name=&apos;__start__&apos;, error=None, interrupts=()),),
    )
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-2.aOmX_lxG.png&amp;amp;w=1650&amp;amp;h=647&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;5. 重放 (Replay)&lt;/h2&gt;
&lt;p&gt;重放功能会从先前的检查点重新执行步骤。使用先前的checkpoint_id调用图，以重新运行该检查点之后的节点。检查点之前的节点会被跳过（其结果已保存）。检查点之后的节点会重新执行，包括任何大模型调用、API 请求或中断—— 这些在重放过程中始终会被重新触发。&lt;/p&gt;
&lt;p&gt;此事在前面time travel初探有所提及。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-3.2m39Gcxz.png&amp;amp;w=1650&amp;amp;h=715&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;6. 状态更新&lt;/h2&gt;
&lt;p&gt;可以使用update_state编辑图状态。这会基于更新后的值创建一个新的检查点，不会修改原始检查点。该更新的处理方式与节点更新一致：若定义了reducer函数，值会通过该函数传递，因此带有 reducer 的通道会累加数值而非覆盖。
你可以可选指定as_node，以控制该更新被视为来自哪个节点，这会影响下一个执行的节点。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-4.DlysrQDO.png&amp;amp;w=3705&amp;amp;h=2598&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;7. 记忆存储&lt;/h2&gt;
&lt;p&gt;仅依靠 checkpointer 无法在线程间共享信息。&lt;br /&gt;
checkpointer 负责“线程内状态持久化”，Store 负责“跨线程共享长期记忆”。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-5.DOTeovDL.png&amp;amp;w=1482&amp;amp;h=777&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;7.1 Store 的核心概念&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Store 中的数据按 &lt;code&gt;namespace&lt;/code&gt;（命名空间）组织，通常使用元组，例如：&lt;code&gt;(user_id, &quot;memories&quot;)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;每条记忆是 &lt;code&gt;key-value&lt;/code&gt; 结构：&lt;code&gt;key&lt;/code&gt; 是记忆 ID，&lt;code&gt;value&lt;/code&gt; 是实际内容（通常为字典）。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;search&lt;/code&gt; 返回的是 &lt;code&gt;Item&lt;/code&gt; 对象，常见字段有：
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;value&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;namespace&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;说明：&lt;code&gt;namespace&lt;/code&gt; 的类型是 &lt;code&gt;tuple[str, ...]&lt;/code&gt;，在 JSON 展示中可能表现为列表。&lt;/p&gt;
&lt;p&gt;举个例子，InMemoryStore 是存在当前 Python 进程的内存（RAM）里，来达到跨线程（thread_id）的效果，注意这里的线程并非是os的线程。&lt;/p&gt;
&lt;h3&gt;7.2 基础用法（脱离图单独使用）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import uuid
from langgraph.store.memory import InMemoryStore

store = InMemoryStore()

user_id = &quot;1&quot;
namespace = (user_id, &quot;memories&quot;)

memory_id = str(uuid.uuid4())
memory = {&quot;food_preference&quot;: &quot;I like pizza&quot;}

store.put(namespace, memory_id, memory)

memories = store.search(namespace)
print(memories[-1].dict())
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.3 在 LangGraph 中接入 Store&lt;/h3&gt;
&lt;p&gt;常见做法是同时编译：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;checkpointer&lt;/code&gt;：保存线程内状态（checkpoint）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;store&lt;/code&gt;：保存跨线程长期记忆&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass
from langgraph.graph import StateGraph, MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

@dataclass
class Context:
    user_id: str

checkpointer = InMemorySaver()
store = InMemoryStore()

builder = StateGraph(MessagesState, context_schema=Context)
# ... add nodes / edges ...
graph = builder.compile(checkpointer=checkpointer, store=store)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调用时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;configurable.thread_id&lt;/code&gt; 用于线程内状态&lt;/li&gt;
&lt;li&gt;&lt;code&gt;context.user_id&lt;/code&gt; 用于跨线程记忆命名空间&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}

for update in graph.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;hi&quot;}]},
    config,
    stream_mode=&quot;updates&quot;,
    context=Context(user_id=&quot;1&quot;),
):
    print(update)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.4 在节点中读写记忆（Runtime 注入）&lt;/h3&gt;
&lt;p&gt;在节点函数参数中声明 &lt;code&gt;Runtime&lt;/code&gt;，即可访问 &lt;code&gt;runtime.store&lt;/code&gt; 与 &lt;code&gt;runtime.context&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import uuid
from dataclasses import dataclass
from langgraph.runtime import Runtime
from langgraph.graph import MessagesState

@dataclass
class Context:
    user_id: str

async def update_memory(state: MessagesState, runtime: Runtime[Context]):
    user_id = runtime.context.user_id
    namespace = (user_id, &quot;memories&quot;)

    memory_id = str(uuid.uuid4())
    await runtime.store.aput(
        namespace,
        memory_id,
        {&quot;memory&quot;: state[&quot;messages&quot;][-1].content},
    )
    return {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读取并用于模型调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def call_model(state: MessagesState, runtime: Runtime[Context]):
    user_id = runtime.context.user_id
    namespace = (user_id, &quot;memories&quot;)

    memories = await runtime.store.asearch(
        namespace,
        query=state[&quot;messages&quot;][-1].content,
        limit=3,
    )
    memory_text = &quot;\n&quot;.join([m.value[&quot;memory&quot;] for m in memories])
    # 将 memory_text 拼接到 prompt 中再调用模型
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.5 跨线程共享记忆&lt;/h3&gt;
&lt;p&gt;只要 &lt;code&gt;user_id&lt;/code&gt; 相同，即使 &lt;code&gt;thread_id&lt;/code&gt; 不同，也可读取到同一份 Store 记忆。&lt;br /&gt;
这正是“会话内状态（thread）”和“长期用户记忆（store）”的分工。&lt;/p&gt;
&lt;h3&gt;7.6 语义检索（Semantic Search）&lt;/h3&gt;
&lt;p&gt;Store 支持语义检索。为 Store 配置 embedding 后，可以用自然语言 query 搜索记忆。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.embeddings import init_embeddings
from langgraph.store.memory import InMemoryStore

store = InMemoryStore(
    index={
        &quot;embed&quot;: init_embeddings(&quot;openai:text-embedding-3-small&quot;),
        &quot;dims&quot;: 1536,
        &quot;fields&quot;: [&quot;$&quot;],  # 或指定具体字段，如 [&quot;food_preference&quot;]
    }
)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;memories = store.search(
    (&quot;1&quot;, &quot;memories&quot;),
    query=&quot;What does the user like to eat?&quot;,
    limit=3,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一些建议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;InMemoryStore&lt;/code&gt; 适合开发与测试，生产环境应使用持久化 Store（如 &lt;code&gt;PostgresStore&lt;/code&gt;、&lt;code&gt;RedisStore&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;若节点需要访问 Store，不要直接依赖全局变量，优先通过 &lt;code&gt;Runtime&lt;/code&gt; 注入访问 &lt;code&gt;runtime.store&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;设计命名空间时建议固定规则（如 &lt;code&gt;(user_id, &quot;memories&quot;)&lt;/code&gt;），便于检索与维护。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>FastAPI 起步：应用入口、fastapi dev、entrypoint 与 uvicorn</title><link>https://owen571.top/posts/study/fastapi/01-fastapi-%E8%B5%B7%E6%AD%A5-%E5%BA%94%E7%94%A8%E5%85%A5%E5%8F%A3-fastapi-dev-%E4%B8%8E-uvicorn/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/01-fastapi-%E8%B5%B7%E6%AD%A5-%E5%BA%94%E7%94%A8%E5%85%A5%E5%8F%A3-fastapi-dev-%E4%B8%8E-uvicorn/</guid><description>从第一个 FastAPI 应用开始，把 app 实例、fastapi dev、pyproject entrypoint、uvicorn 以及 async 并发直觉一次串起来。</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;官方教程的第一步其实很适合直接上手，因为 FastAPI 的最小应用非常小，小到可以一下子把“应用对象、路径函数、自动文档”三件事一起看到。&lt;/p&gt;
&lt;h2&gt;1. 第一个 FastAPI 应用到底做了什么&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI

app = FastAPI()


@app.get(&quot;/&quot;)
async def root():
    return {&quot;message&quot;: &quot;Hello World&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这几行已经把 FastAPI 最关键的骨架全摆出来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;app = FastAPI()&lt;/code&gt;：创建应用对象&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@app.get(&quot;/&quot;)&lt;/code&gt;：注册一个处理 &lt;code&gt;GET /&lt;/code&gt; 的路径操作&lt;/li&gt;
&lt;li&gt;&lt;code&gt;async def root()&lt;/code&gt;：定义真正处理请求的函数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一旦跑起来，FastAPI 会自动生成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/openapi.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/docs&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/redoc&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 FastAPI 的一个核心体验就是：写代码本身，也是在写接口 schema。&lt;/p&gt;
&lt;h2&gt;2. &lt;code&gt;fastapi dev&lt;/code&gt; 是什么&lt;/h2&gt;
&lt;p&gt;FastAPI 自带了 CLI。官方文档里把它单独拆成了一页，但第一次接触时，最重要的其实就是先记住开发模式命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fastapi dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档说明，安装 &lt;code&gt;fastapi[standard]&lt;/code&gt; 时，会附带 &lt;code&gt;fastapi&lt;/code&gt; 这个命令行程序；开发环境里直接用 &lt;code&gt;fastapi dev&lt;/code&gt; 即可启动开发服务器。它会自动热重载，所以改代码时服务会自动重启。&lt;br /&gt;
来源：FastAPI CLI 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/fastapi-cli/&quot;&gt;https://fastapi.tiangolo.com/zh/fastapi-cli/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;如果当前目录里正好是标准结构，比如有一个 &lt;code&gt;main.py&lt;/code&gt; 并且里面有 &lt;code&gt;app = FastAPI()&lt;/code&gt;，那这条命令通常就够了。&lt;/p&gt;
&lt;h2&gt;3. &lt;code&gt;entrypoint&lt;/code&gt; 为什么值得早一点知道&lt;/h2&gt;
&lt;p&gt;官方在 First Steps 里补了一个很有用的点：可以在 &lt;code&gt;pyproject.toml&lt;/code&gt; 里配置应用入口。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[tool.fastapi]
entrypoint = &quot;main:app&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果代码不在根目录，而是在 &lt;code&gt;backend/main.py&lt;/code&gt; 里，那么可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[tool.fastapi]
entrypoint = &quot;backend.main:app&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它本质上是在告诉 &lt;code&gt;fastapi&lt;/code&gt; 命令：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;去哪个模块找应用&lt;/li&gt;
&lt;li&gt;应用对象名字是什么&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这件事看起来像小细节，但对多文件工程很重要，因为它会让 &lt;code&gt;fastapi dev&lt;/code&gt;、工具链、编辑器扩展都更容易找到你的应用入口。&lt;br /&gt;
来源：First Steps 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/first-steps/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/first-steps/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;4. 为什么还要知道 &lt;code&gt;uvicorn&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;FastAPI 本身是 Web 框架，但真正负责接收 HTTP 请求、跑事件循环、把 ASGI 应用跑起来的，通常是 ASGI 服务器。&lt;/p&gt;
&lt;p&gt;最常见的就是 &lt;code&gt;uvicorn&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;官方手动部署页里明确提到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;安装 &lt;code&gt;fastapi[standard]&lt;/code&gt; 时也会安装 &lt;code&gt;uvicorn[standard]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uvicorn[standard]&lt;/code&gt; 里包含了像 &lt;code&gt;uvloop&lt;/code&gt; 这样的推荐依赖&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，FastAPI 和 Uvicorn 的关系可以简单记成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FastAPI：定义应用逻辑&lt;/li&gt;
&lt;li&gt;Uvicorn：真正把这个 ASGI 应用跑起来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;来源：手动运行服务器官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/deployment/manually/&quot;&gt;https://fastapi.tiangolo.com/zh/deployment/manually/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;5. &lt;code&gt;fastapi dev&lt;/code&gt;、&lt;code&gt;fastapi run&lt;/code&gt;、&lt;code&gt;uvicorn main:app&lt;/code&gt; 到底是什么关系&lt;/h2&gt;
&lt;p&gt;这一点官方文档是分散着讲的，第一次学时很容易混。&lt;/p&gt;
&lt;p&gt;可以直接这样记：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fastapi dev&lt;/code&gt;：开发模式，带自动重载&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fastapi run&lt;/code&gt;：CLI 的生产模式入口&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uvicorn main:app&lt;/code&gt;：直接手动启动 ASGI 服务器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;官方 CLI 页里明确写到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;开发环境用 &lt;code&gt;fastapi dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;生产环境用 &lt;code&gt;fastapi run&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;FastAPI CLI 内部实际也是基于 Uvicorn&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而手动运行页里则说明了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvicorn main:app --host 0.0.0.0 --port 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;main:app&lt;/code&gt; 含义是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;main&lt;/code&gt;：&lt;code&gt;main.py&lt;/code&gt; 这个模块&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app&lt;/code&gt;：模块里的 &lt;code&gt;app = FastAPI()&lt;/code&gt; 对象&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它等价于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from main import app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以这三者并不冲突，只是站在不同层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fastapi dev&lt;/code&gt; / &lt;code&gt;fastapi run&lt;/code&gt;：更像 FastAPI 提供的易用封装&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uvicorn main:app&lt;/code&gt;：直接操作 ASGI 服务器&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. &lt;code&gt;--reload&lt;/code&gt; 为什么只该停留在开发阶段&lt;/h2&gt;
&lt;p&gt;官方手动运行页也特别提醒了 &lt;code&gt;--reload&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它对开发很有用，因为改代码会自动重启。但它的本质是“开发便利”，不是生产能力。所以在生产部署里，通常不会把 &lt;code&gt;--reload&lt;/code&gt; 一直开着。&lt;/p&gt;
&lt;p&gt;因此最常见的分工是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvicorn main:app --reload
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用在本地开发；&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fastapi run main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvicorn main:app --host 0.0.0.0 --port 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更接近部署和容器场景。&lt;/p&gt;
&lt;h2&gt;7. 调试时把 &lt;code&gt;uvicorn.run()&lt;/code&gt; 写进 &lt;code&gt;__main__&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;你本地笔记里还写到了这种方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get(&quot;/&quot;)
def root():
    return {&quot;hello&quot;: &quot;world&quot;}


if __name__ == &quot;__main__&quot;:
    uvicorn.run(app, host=&quot;0.0.0.0&quot;, port=8000)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法不是 FastAPI 官方主推的日常运行方式，但在本地直接调试时很顺手，尤其是你已经把应用写成一个普通 Python 文件、想直接点运行的时候。&lt;/p&gt;
&lt;p&gt;它更像“开发时的 Python 入口”，而不是部署命令。&lt;/p&gt;
&lt;h2&gt;8. FastAPI 为什么能直接支持 &lt;code&gt;async def&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;FastAPI 的另一个关键体验，是路径函数可以自然写成 &lt;code&gt;async def&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import asyncio
from fastapi import FastAPI

app = FastAPI()


async def make_burger(name: str, seconds: int):
    await asyncio.sleep(seconds)
    return name


@app.get(&quot;/burgers/{count}&quot;)
async def order_burgers(count: int):
    tasks = [
        asyncio.create_task(make_burger(f&quot;汉堡{i + 1}&quot;, 3 + i))
        for i in range(count)
    ]
    burgers = await asyncio.gather(*tasks)
    return {&quot;count&quot;: count, &quot;burgers&quot;: burgers}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个“做汉堡”例子重要的地方不在汉堡，而在于它很适合建立一个直觉：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I/O 密集型任务适合异步&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await&lt;/code&gt; 的意义不是“更快”，而是“等待时别堵住整个服务”&lt;/li&gt;
&lt;li&gt;FastAPI 对 &lt;code&gt;async def&lt;/code&gt; 的支持不是附加功能，而是默认工作方式的一部分&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果路径函数写成普通 &lt;code&gt;def&lt;/code&gt;，FastAPI 也能处理。它会把同步函数放到线程池里执行，避免直接阻塞事件循环。这个兜底机制意味着：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;能异步就异步&lt;/li&gt;
&lt;li&gt;还没异步化的同步逻辑也能先跑起来&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>量化入门：为什么要量化、量化怎么做、常见方法有哪些</title><link>https://owen571.top/posts/study/fine-tuning/02-%E9%87%8F%E5%8C%96%E5%85%A5%E9%97%A8-%E5%8E%9F%E7%90%86%E5%88%86%E7%B1%BB%E4%B8%8E%E5%B8%B8%E8%A7%81%E6%96%B9%E6%B3%95/</link><guid isPermaLink="true">https://owen571.top/posts/study/fine-tuning/02-%E9%87%8F%E5%8C%96%E5%85%A5%E9%97%A8-%E5%8E%9F%E7%90%86%E5%88%86%E7%B1%BB%E4%B8%8E%E5%B8%B8%E8%A7%81%E6%96%B9%E6%B3%95/</guid><description>把原笔记里和量化相关的部分单独抽出来：先讲目的，再讲原理、分类和常用方法，最后把它和 QLoRA 重新连回到微调主线里。</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;量化和微调经常一起出现，尤其一说到 QLoRA 就会默认它们属于一套东西。但更顺的理解方式其实是先把量化单独拆开：它先是模型压缩与推理优化技术，然后才在 QLoRA 里与微调发生结合。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一、为什么要量化&lt;/h1&gt;
&lt;p&gt;模型量化的核心，是把高精度数据（通常是 FP32）转换成更低精度的数据表示。&lt;/p&gt;
&lt;p&gt;它最直接的目标有三个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;减少模型大小&lt;/li&gt;
&lt;li&gt;降低显存或内存占用&lt;/li&gt;
&lt;li&gt;提升推理速度&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;代价也很明确：精度可能损失。&lt;/p&gt;
&lt;p&gt;所以量化从来都不是“白送的加速”，而是一种典型的精度与资源交换。&lt;/p&gt;
&lt;h1&gt;二、量化的原理&lt;/h1&gt;
&lt;p&gt;量化本质上是在做一件事：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;把连续浮点区间，映射到有限的离散整数区间。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250830233206.png&quot; alt=&quot;量化原理示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;原笔记里的几个核心量是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x_fp&lt;/code&gt;：原始浮点值&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x_int&lt;/code&gt;：量化后的整数值&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scale&lt;/code&gt;：缩放因子&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zero_point&lt;/code&gt;：零点偏移&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可以把它粗理解为“比例缩放 + 偏移对齐”。&lt;/p&gt;
&lt;h1&gt;三、量化可以怎么分类&lt;/h1&gt;
&lt;h2&gt;1. 按量化时机&lt;/h2&gt;
&lt;h3&gt;（1）训练后量化（PTQ）&lt;/h3&gt;
&lt;p&gt;先正常训练，再在模型训练完之后直接量化。&lt;/p&gt;
&lt;p&gt;优点是快、实现简单；缺点是精度损失可能比较明显，尤其在小模型上。&lt;/p&gt;
&lt;p&gt;QLoRA 里常见的 4-bit NF4 量化，本质上也是“微调前先把基础模型量化”。&lt;/p&gt;
&lt;h3&gt;（2）量化感知训练（QAT）&lt;/h3&gt;
&lt;p&gt;在训练时就模拟量化和反量化过程，让模型提前适应低精度带来的误差。&lt;/p&gt;
&lt;p&gt;这类方法通常更稳，但实现复杂度和训练成本更高。&lt;/p&gt;
&lt;h2&gt;2. 按量化精度&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;权重精度&lt;/th&gt;
&lt;th&gt;激活值精度&lt;/th&gt;
&lt;th&gt;特点&lt;/th&gt;
&lt;th&gt;代表技术&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FP16 / BF16&lt;/td&gt;
&lt;td&gt;16-bit&lt;/td&gt;
&lt;td&gt;16-bit&lt;/td&gt;
&lt;td&gt;更偏训练加速与存储节省，通常可视为近乎无损&lt;/td&gt;
&lt;td&gt;AMP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INT8&lt;/td&gt;
&lt;td&gt;8-bit&lt;/td&gt;
&lt;td&gt;8-bit&lt;/td&gt;
&lt;td&gt;最主流的推理精度，精度和效率平衡较好&lt;/td&gt;
&lt;td&gt;TensorRT, ONNX Runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INT4 / NF4&lt;/td&gt;
&lt;td&gt;4-bit&lt;/td&gt;
&lt;td&gt;8-bit / 4-bit&lt;/td&gt;
&lt;td&gt;极致压缩，适合消费级硬件跑大模型&lt;/td&gt;
&lt;td&gt;QLoRA, GPTQ, AWQ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1-bit / 2-bit&lt;/td&gt;
&lt;td&gt;1/2-bit&lt;/td&gt;
&lt;td&gt;32-bit / 1-bit&lt;/td&gt;
&lt;td&gt;学术前沿，压缩极致，但落地难&lt;/td&gt;
&lt;td&gt;BinaryConnect&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;3. 按量化对象&lt;/h2&gt;
&lt;h3&gt;（1）仅权重量化&lt;/h3&gt;
&lt;p&gt;只量化模型权重，激活值仍然保留较高精度。&lt;/p&gt;
&lt;h3&gt;（2）权重与激活值全量化&lt;/h3&gt;
&lt;p&gt;推理过程中权重和激活值都被量化到低精度，进一步压缩，但实现难度更高。&lt;/p&gt;
&lt;h2&gt;4. 按量化策略&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;原理&lt;/th&gt;
&lt;th&gt;优点&lt;/th&gt;
&lt;th&gt;缺点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;对称量化&lt;/td&gt;
&lt;td&gt;把 &lt;code&gt;[-α, α]&lt;/code&gt; 映射到对称整数区间，&lt;code&gt;zero_point = 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;计算简单&lt;/td&gt;
&lt;td&gt;不适合明显偏斜的数据分布&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;非对称量化&lt;/td&gt;
&lt;td&gt;把 &lt;code&gt;[β, α]&lt;/code&gt; 映射到非对称整数区间，&lt;code&gt;zero_point ≠ 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;更充分利用整数区间，误差更小&lt;/td&gt;
&lt;td&gt;计算稍复杂&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h1&gt;四、常见量化方法&lt;/h1&gt;
&lt;h2&gt;1. bitsandbytes（bnb）&lt;/h2&gt;
&lt;p&gt;这是 QLoRA 最经典也最常见的配套库，支持 8-bit 与 4-bit，尤其 4-bit NF4 是它的代表能力之一。&lt;/p&gt;
&lt;p&gt;优点是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;社区最成熟&lt;/li&gt;
&lt;li&gt;Hugging Face 生态集成最好&lt;/li&gt;
&lt;li&gt;对 LoRA / QLoRA 训练非常友好&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. HQQ&lt;/h2&gt;
&lt;p&gt;HQQ 的特点是尽量减少对校准数据的依赖。相比传统量化要先拿一批校准样本统计分布，HQQ 更强调快速量化和更灵活的冷启动。&lt;/p&gt;
&lt;h2&gt;3. EETQ&lt;/h2&gt;
&lt;p&gt;EETQ 是 NVIDIA 推出的 8-bit 推理方案，更偏高吞吐的工程落地路线，和 TensorRT 这类 NVIDIA 生态结合得更深。&lt;/p&gt;
&lt;h1&gt;五、量化和微调是怎么接起来的&lt;/h1&gt;
&lt;p&gt;到这里，量化还只是“压缩模型”的技术。但一旦和 LoRA 结合起来，它就从纯推理优化，变成了一条真正改变训练门槛的路线。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250830225340.png&quot; alt=&quot;QLoRA 示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;QLoRA 的关键思路是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先把基础模型量化到 4-bit&lt;/li&gt;
&lt;li&gt;冻结这些量化权重&lt;/li&gt;
&lt;li&gt;只训练 LoRA 增量参数&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样做的好处不是“量化本身更聪明”，而是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;把原本需要很大显存才能做的微调，压到了普通单机甚至消费级设备也能尝试的区间。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;所以量化在这里的价值，不只是推理更省资源，而是直接改变了“谁有能力做微调”的门槛。&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 核心能力 02：Durable Execution 与 task 封装</title><link>https://owen571.top/posts/study/langgraph/03-langgraph-%E6%8C%81%E4%B9%85%E6%89%A7%E8%A1%8C-durable-execution-%E4%B8%8E-task/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/03-langgraph-%E6%8C%81%E4%B9%85%E6%89%A7%E8%A1%8C-durable-execution-%E4%B8%8E-task/</guid><description>理解 LangGraph 为什么强调 durable execution，以及为什么把副作用包进 task 会比直接写在 node 里更稳。</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇最值得记住的一句话是：LangGraph 的恢复不是从“代码那一行”继续，而是从某个可回放起点重新执行。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;持久执行是一种技术，进程或工作流会在关键节点保存进度，使其能够暂停，并在后续从断点处精准恢复执行。该技术在需要human-in-loop的场景中尤为实用 —— 用户可在流程继续前进行检查、验证或修改；同时也适用于可能遭遇中断或错误的长时间运行任务（例如调用大模型超时）。通过保留已完成的工作，持久执行可让进程无需重复处理先前步骤即可恢复，即便间隔时间较长（例如一周后）也能实现。&lt;/p&gt;
&lt;p&gt;LangGraph 内置的持久化层为工作流提供持久执行能力，确保每个执行步骤的状态都保存至持久化存储中。这一特性保证，无论工作流是因系统故障中断，还是为了human-in-loop交互而暂停，都能从最后记录的状态恢复执行。&lt;/p&gt;
&lt;p&gt;值得注意的是，只要用了 checkpointer，就已经开启 durable execution，但是恢复时不是从“代码那一行”继续，而是从某个可重放起点重跑到中断处。所以要把“副作用/不确定操作”（API 调用、写文件、随机数）包进 task，并尽量做幂等。另外，选择durability 模式：exit / async / sync。&lt;/p&gt;
&lt;p&gt;提一下幂等(idempotent)，它是指同一个操作执行 1 次和执行多次，结果一样。比如把用户语言设置为 zh 就是幂等的，而余额+100则是非幂等的。你在 LangGraph durable execution 里会遇到它，是因为失败重试/回放可能重复执行。&lt;/p&gt;
&lt;p&gt;解决操作不幂等的常见解法，是给一次业务操作生成唯一的幂等键（idempotency_key），下游根据key去重。&lt;/p&gt;
&lt;h2&gt;示例对比：直接在 node 中请求 vs 用 &lt;code&gt;@task&lt;/code&gt; 封装请求&lt;/h2&gt;
&lt;p&gt;这两段代码核心区别，在这里。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import NotRequired
from typing_extensions import TypedDict
import uuid

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
import requests

# Define a TypedDict to represent the state
class State(TypedDict):
    url: str
    result: NotRequired[str]

def call_api(state: State):
    &quot;&quot;&quot;Example node that makes an API request.&quot;&quot;&quot;
    result = requests.get(state[&apos;url&apos;]).text[:100]  # Side-effect  #
    return {
        &quot;result&quot;: result
    }

# Create a StateGraph builder and add a node for the call_api function
builder = StateGraph(State)
builder.add_node(&quot;call_api&quot;, call_api)

# Connect the start and end nodes to the call_api node
builder.add_edge(START, &quot;call_api&quot;)
builder.add_edge(&quot;call_api&quot;, END)

# Specify a checkpointer
checkpointer = InMemorySaver()

# Compile the graph with the checkpointer
graph = builder.compile(checkpointer=checkpointer)

# Define a config with a thread ID.
thread_id = uuid.uuid4()
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: thread_id}}

# Invoke the graph
graph.invoke({&quot;url&quot;: &quot;https://www.example.com&quot;}, config)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from typing import NotRequired
from typing_extensions import TypedDict
import uuid

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.func import task
from langgraph.graph import StateGraph, START, END
import requests

# Define a TypedDict to represent the state
class State(TypedDict):
    urls: list[str]
    result: NotRequired[list[str]]


@task
def _make_request(url: str):
    &quot;&quot;&quot;Make a request.&quot;&quot;&quot;
    return requests.get(url).text[:100]

def call_api(state: State):
    &quot;&quot;&quot;Example node that makes an API request.&quot;&quot;&quot;
    requests = [_make_request(url) for url in state[&apos;urls&apos;]]
    results = [request.result() for request in requests]
    return {
        &quot;results&quot;: results
    }

# Create a StateGraph builder and add a node for the call_api function
builder = StateGraph(State)
builder.add_node(&quot;call_api&quot;, call_api)

# Connect the start and end nodes to the call_api node
builder.add_edge(START, &quot;call_api&quot;)
builder.add_edge(&quot;call_api&quot;, END)

# Specify a checkpointer
checkpointer = InMemorySaver()

# Compile the graph with the checkpointer
graph = builder.compile(checkpointer=checkpointer)

# Define a config with a thread ID.
thread_id = uuid.uuid4()
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: thread_id}}

# Invoke the graph
graph.invoke({&quot;urls&quot;: [&quot;https://www.example.com&quot;]}, config)
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比维度&lt;/th&gt;
&lt;th&gt;直接在 node 里 &lt;code&gt;requests.get()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;用 &lt;code&gt;@task&lt;/code&gt; 封装 &lt;code&gt;_make_request()&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;副作用位置&lt;/td&gt;
&lt;td&gt;副作用直接写在 node 内&lt;/td&gt;
&lt;td&gt;副作用被隔离到 task 内&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;恢复/重放时行为&lt;/td&gt;
&lt;td&gt;node 可能被重放，副作用可能重复触发&lt;/td&gt;
&lt;td&gt;已成功完成的 task 结果可被复用，减少重复副作用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;失败恢复粒度&lt;/td&gt;
&lt;td&gt;粒度较粗，通常按 node 重新执行&lt;/td&gt;
&lt;td&gt;粒度更细，按 task 级别恢复更可控&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;代码组织&lt;/td&gt;
&lt;td&gt;简单直接，但不利于 durable 场景&lt;/td&gt;
&lt;td&gt;结构更清晰，适合长流程和容错&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;推荐场景&lt;/td&gt;
&lt;td&gt;一次性、无副作用、演示代码&lt;/td&gt;
&lt;td&gt;生产或半生产，涉及 API/IO/不确定操作&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;为什么官方推荐第二种&lt;/h2&gt;
&lt;p&gt;在 durable execution 中，恢复不是回到某一行代码，而是从某个可重放起点继续。&lt;br /&gt;
如果副作用写在 node 里，重放时容易重复调用外部 API。&lt;br /&gt;
把副作用放进 &lt;code&gt;@task&lt;/code&gt;，可以让 LangGraph 更好地记录和复用已完成工作，减少重复执行风险。&lt;/p&gt;
&lt;h2&gt;这两个例子的结论&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;第一段代码可运行，但更像“最小示例”，适合理解流程。&lt;/li&gt;
&lt;li&gt;第二段代码是 durable execution 更推荐的写法，尤其是有外部 API 调用时。&lt;/li&gt;
&lt;li&gt;即便用了 task，也应尽量保证调用幂等（例如带幂等键），因为失败重试时仍可能重跑未成功完成的 task。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;小修正（你的第二段代码）&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;State&lt;/code&gt; 里写的是 &lt;code&gt;result: NotRequired[list[str]]&lt;/code&gt;，但返回值是 &lt;code&gt;{&quot;results&quot;: results}&lt;/code&gt;。&lt;br /&gt;
字段名建议统一为一个，例如都用 &lt;code&gt;results&lt;/code&gt;，避免状态键不一致。&lt;/p&gt;
</content:encoded></item><item><title>FastAPI 输入基础：路径参数与查询参数</title><link>https://owen571.top/posts/study/fastapi/02-fastapi-%E8%B7%AF%E5%BE%84%E5%8F%82%E6%95%B0%E4%B8%8E%E6%9F%A5%E8%AF%A2%E5%8F%82%E6%95%B0/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/02-fastapi-%E8%B7%AF%E5%BE%84%E5%8F%82%E6%95%B0%E4%B8%8E%E6%9F%A5%E8%AF%A2%E5%8F%82%E6%95%B0/</guid><description>把 URL 上最常见的两类输入拆开：路径参数负责定位资源，查询参数负责表达筛选和附加条件。</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;官方把路径参数和查询参数拆成了两个章节，这样查资料很舒服；连续学习时，把它们放在一起会更顺，因为它们本质上都在回答同一个问题：请求里的输入，先从 URL 的哪一层进来。&lt;/p&gt;
&lt;h2&gt;1. 路径参数：资源定位的一部分&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI

app = FastAPI()


@app.get(&quot;/items/{item_id}&quot;)
async def read_item(item_id: str):
    return {&quot;item_id&quot;: item_id}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;item_id&lt;/code&gt; 不只是一个字符串变量，而是 URL 路径的一部分。&lt;/p&gt;
&lt;p&gt;FastAPI 会根据类型注解自动解析：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;写成 &lt;code&gt;str&lt;/code&gt;，&lt;code&gt;/items/foo&lt;/code&gt; 和 &lt;code&gt;/items/4&lt;/code&gt; 都行&lt;/li&gt;
&lt;li&gt;写成 &lt;code&gt;int&lt;/code&gt;，传 &lt;code&gt;foo&lt;/code&gt; 会自动报校验错误&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以它不是“先拿到字符串，再自己转”，而是直接把 Python 类型系统接到了请求解析层。&lt;/p&gt;
&lt;h2&gt;2. 路径匹配的顺序很重要&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/users/me&quot;)
async def read_user_me():
    return {&quot;user_id&quot;: &quot;the current user&quot;}


@app.get(&quot;/users/{user_id}&quot;)
async def read_user(user_id: str):
    return {&quot;user_id&quot;: user_id}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/users/me&lt;/code&gt; 必须写在 &lt;code&gt;/users/{user_id}&lt;/code&gt; 前面，否则 &lt;code&gt;me&lt;/code&gt; 会被当成普通的 &lt;code&gt;user_id&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里很容易以为 FastAPI 会自动优先匹配更具体的路径，但实际还是要考虑声明顺序。&lt;/p&gt;
&lt;h2&gt;3. 枚举路径参数&lt;/h2&gt;
&lt;p&gt;当路径参数只能从一组有限值里选时，可以直接用 &lt;code&gt;Enum&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from enum import Enum


class ModelName(str, Enum):
    alexnet = &quot;alexnet&quot;
    resnet = &quot;resnet&quot;
    lenet = &quot;lenet&quot;


@app.get(&quot;/models/{model_name}&quot;)
async def get_model(model_name: ModelName):
    if model_name is ModelName.alexnet:
        return {&quot;model_name&quot;: model_name, &quot;message&quot;: &quot;Deep Learning FTW!&quot;}
    if model_name.value == &quot;lenet&quot;:
        return {&quot;model_name&quot;: model_name, &quot;message&quot;: &quot;LeCNN all the images&quot;}
    return {&quot;model_name&quot;: model_name, &quot;message&quot;: &quot;Have some residuals&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样文档里会直接展示可选值，而不是一个自由输入框。&lt;/p&gt;
&lt;h2&gt;4. 路径转换器&lt;/h2&gt;
&lt;p&gt;有时候变量本身还想继续吃掉路径，可以用 Starlette 的路径转换器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/files/{file_path:path}&quot;)
async def read_file(file_path: str):
    return {&quot;file_path&quot;: file_path}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;:path&lt;/code&gt; 让 &lt;code&gt;file_path&lt;/code&gt; 可以包含 &lt;code&gt;/&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;5. 查询参数：&lt;code&gt;?&lt;/code&gt; 后面的附加条件&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI

app = FastAPI()

fake_items_db = [{&quot;item_name&quot;: &quot;Foo&quot;}, {&quot;item_name&quot;: &quot;Bar&quot;}, {&quot;item_name&quot;: &quot;Baz&quot;}]


@app.get(&quot;/items/&quot;)
async def read_items(skip: int = 0, limit: int = 10):
    return fake_items_db[skip : skip + limit]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应请求可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/items/?skip=0&amp;amp;limit=10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要参数不是路径参数，FastAPI 默认就会把它解释成查询参数。&lt;/p&gt;
&lt;h2&gt;6. 路径参数和查询参数可以同时存在&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/items/{item_id}&quot;)
async def read_item(
    item_id: str,
    p: str = &quot;test&quot;,
    q: str | None = None,
    short: bool = False,
):
    item = {&quot;item_id&quot;: item_id}
    if q:
        item.update({&quot;q_info&quot;: f&quot;q传入了参数{q}&quot;})
    if p:
        item.update({&quot;p_info&quot;: &quot;测试成功，默认查询了p&quot;})
    if short:
        item.update({&quot;short_info&quot;: &quot;你真传了short啊&quot;})
    return item
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;item_id&lt;/code&gt; 是路径参数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;p&lt;/code&gt;、&lt;code&gt;q&lt;/code&gt;、&lt;code&gt;short&lt;/code&gt; 是查询参数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;布尔查询参数也会自动做转换。像 &lt;code&gt;1&lt;/code&gt;、&lt;code&gt;true&lt;/code&gt;、&lt;code&gt;on&lt;/code&gt; 都会被识别成 &lt;code&gt;True&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;7. 一开始最值得建立的区分&lt;/h2&gt;
&lt;p&gt;这一阶段最重要的不是背更多 API，而是先把 URL 上的输入层分开：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;路径参数：属于资源标识的一部分&lt;/li&gt;
&lt;li&gt;查询参数：属于对本次请求的额外说明&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后面无论进入请求体、表单还是依赖注入，只要这个区分先站稳，阅读体验会顺很多。&lt;/p&gt;
</content:encoded></item><item><title>微调数据集：Alpaca、ShareGPT、多模态格式与 LLaMA-Factory 接入</title><link>https://owen571.top/posts/study/fine-tuning/03-%E5%BE%AE%E8%B0%83%E6%95%B0%E6%8D%AE%E9%9B%86-alpaca-sharegpt-%E4%B8%8E-llama-factory-%E6%8E%A5%E5%85%A5/</link><guid isPermaLink="true">https://owen571.top/posts/study/fine-tuning/03-%E5%BE%AE%E8%B0%83%E6%95%B0%E6%8D%AE%E9%9B%86-alpaca-sharegpt-%E4%B8%8E-llama-factory-%E6%8E%A5%E5%85%A5/</guid><description>先把数据组织方式搞清楚：Alpaca 和 ShareGPT 有什么差别，多模态样本通常怎么写，以及 LLaMA-Factory 的 dataset_info 如何接入自己的数据。</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;真正开始微调时，最容易被低估的往往不是模型，而是数据格式。模型再强，如果数据结构和训练框架对不上，后面基本都会卡住。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一、常见数据格式：Alpaca 与 ShareGPT&lt;/h1&gt;
&lt;h2&gt;1. Alpaca&lt;/h2&gt;
&lt;p&gt;Alpaca 最初来自斯坦福大学发布的 52k 指令微调数据集。后来“Alpaca 格式”逐渐被社区抽象成一类更通用的单轮任务数据结构，适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;问答&lt;/li&gt;
&lt;li&gt;翻译&lt;/li&gt;
&lt;li&gt;摘要&lt;/li&gt;
&lt;li&gt;结构化生成&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的核心特征是围绕下面几类字段组织：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;instruction&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;input&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;output&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;可选的 &lt;code&gt;system&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;可选的 &lt;code&gt;history&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250831011445.png&quot; alt=&quot;Alpaca 示例 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250831011507.png&quot; alt=&quot;Alpaca 示例 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250831011753.png&quot; alt=&quot;Alpaca 示例 3&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. ShareGPT&lt;/h2&gt;
&lt;p&gt;ShareGPT 更适合多轮对话和复杂交互。它的核心不是单个 &lt;code&gt;instruction&lt;/code&gt;，而是一串 &lt;code&gt;conversations&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它常见的角色包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;human&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gpt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;function_call&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;observation&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此它特别适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多轮聊天&lt;/li&gt;
&lt;li&gt;工具调用&lt;/li&gt;
&lt;li&gt;更接近真实助手场景的数据&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250831012400.png&quot; alt=&quot;ShareGPT 示例 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250831012916.png&quot; alt=&quot;ShareGPT 示例 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250831012643.png&quot; alt=&quot;ShareGPT 示例 3&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250831012955.png&quot; alt=&quot;ShareGPT 示例 4&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;3. 两种格式的差别&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比维度&lt;/th&gt;
&lt;th&gt;Alpaca&lt;/th&gt;
&lt;th&gt;ShareGPT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心目标&lt;/td&gt;
&lt;td&gt;单轮指令驱动任务&lt;/td&gt;
&lt;td&gt;多轮对话与工具调用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据结构&lt;/td&gt;
&lt;td&gt;&lt;code&gt;instruction / input / output&lt;/code&gt; 为主&lt;/td&gt;
&lt;td&gt;&lt;code&gt;conversations&lt;/code&gt; 列表为主&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;多轮历史&lt;/td&gt;
&lt;td&gt;通过 &lt;code&gt;history&lt;/code&gt; 额外表示&lt;/td&gt;
&lt;td&gt;自然体现在对话列表里&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具调用&lt;/td&gt;
&lt;td&gt;不原生支持&lt;/td&gt;
&lt;td&gt;原生支持 &lt;code&gt;function_call / observation&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;典型场景&lt;/td&gt;
&lt;td&gt;指令微调、单轮生成&lt;/td&gt;
&lt;td&gt;聊天助手、工具代理、复杂交互&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;如果只是做单轮任务，Alpaca 往往更直接；如果要训练对话助手或工具流，ShareGPT 更自然。&lt;/p&gt;
&lt;h1&gt;二、多模态数据通常怎么写&lt;/h1&gt;
&lt;p&gt;在多模态微调里，最常见的组织方式是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;your_multimodal_data/
├── images/
├── conversations.json
└── metadata.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中真正关键的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文本对话内容&lt;/li&gt;
&lt;li&gt;图像路径或图像标识&lt;/li&gt;
&lt;li&gt;文本里 &lt;code&gt;&amp;lt;image&amp;gt;&lt;/code&gt; 这类占位符&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一个很典型的多模态样本大致会长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;id&quot;: &quot;unique_conversation_id_1&quot;,
  &quot;image&quot;: &quot;images/image1.jpg&quot;,
  &quot;conversations&quot;: [
    {
      &quot;from&quot;: &quot;human&quot;,
      &quot;value&quot;: &quot;请详细描述这张图片。&amp;lt;image&amp;gt;&quot;
    },
    {
      &quot;from&quot;: &quot;gpt&quot;,
      &quot;value&quot;: &quot;这张图片展示了一只可爱的金色寻回犬在草地上奔跑。&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于 Qwen2.5-VL 这类模型来说，重点不在于格式有多花，而在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;图像路径要正确&lt;/li&gt;
&lt;li&gt;占位符要符合模板&lt;/li&gt;
&lt;li&gt;对话轮次要和任务一致&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;三、训练集、验证集、测试集该怎么分&lt;/h1&gt;
&lt;p&gt;微调时最常见的三类数据是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;训练集：用于更新权重。&lt;/li&gt;
&lt;li&gt;验证集：用于观察训练过程和泛化情况，不参与权重更新。&lt;/li&gt;
&lt;li&gt;测试集：训练和调参全部结束后，最后做客观评估。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;原笔记里对不同数据规模给了一条很实用的经验线：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;大数据集：80/10/10 或 70/15/15&lt;/li&gt;
&lt;li&gt;中等数据集：60/20/20 或 70/20/10&lt;/li&gt;
&lt;li&gt;小数据集：优先考虑交叉验证，或者酌情增大验证 / 测试比例&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;到了多模态任务里，还要额外注意：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;绝对数量比比例更重要&lt;/li&gt;
&lt;li&gt;要尽量做分层抽样&lt;/li&gt;
&lt;li&gt;小数据集更需要认真留测试集&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;四、LLaMA-Factory 里的 &lt;code&gt;dataset_info&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;LLaMA-Factory 会用一个统一的配置文件来登记数据集入口。这个设计很实用，因为它把“训练命令”与“数据来源描述”解耦了。&lt;/p&gt;
&lt;p&gt;核心字段一般包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;file_name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;formatting&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;columns&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是这次笔记里的多模态 ShareGPT 格式数据集，一个典型配置可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;blood_image&quot;: {
  &quot;file_name&quot;: &quot;/data/llm/blood_image/dataset.json&quot;,
  &quot;formatting&quot;: &quot;sharegpt&quot;,
  &quot;columns&quot;: {
    &quot;messages&quot;: &quot;conversations&quot;,
    &quot;images&quot;: &quot;image&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而对应的数据集样本可能是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;id&quot;: &quot;sample_24&quot;,
  &quot;image&quot;: &quot;/data/llm/img2npy/output/滴落/6.png&quot;,
  &quot;conversations&quot;: [
    {
      &quot;from&quot;: &quot;human&quot;,
      &quot;value&quot;: &quot;&amp;lt;image&amp;gt;\n描述这张图片。&quot;
    },
    {
      &quot;from&quot;: &quot;gpt&quot;,
      &quot;value&quot;: &quot;在木质背景上有一滴血液，下面摆放着一把尺子用于测量。&quot;
    },
    {
      &quot;from&quot;: &quot;human&quot;,
      &quot;value&quot;: &quot;这是什么形态的血液？&quot;
    },
    {
      &quot;from&quot;: &quot;gpt&quot;,
      &quot;value&quot;: &quot;这属于被动的/重力类血液中的滴落类型。&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这组例子其实很能说明一个事实：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;微调数据集不是“随便凑成问答”就行，而是要和模型模板、训练框架、任务目标同时对齐。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;五、一组真正会影响训练结果的参数直觉&lt;/h1&gt;
&lt;p&gt;原笔记里还单独整理了几组训练时最常调的参数，这些内容后面会继续用到：&lt;/p&gt;
&lt;h2&gt;1. 训练轮数（Epochs）&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;数据少时往往需要更多轮&lt;/li&gt;
&lt;li&gt;太多又容易过拟合&lt;/li&gt;
&lt;li&gt;一般可以从 3 开始试&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 学习率&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;一般微调任务：&lt;code&gt;5e-5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;更保守：&lt;code&gt;4e-5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;全参数微调：通常更小，比如 &lt;code&gt;1e-5&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 批量大小（Batch Size）&lt;/h2&gt;
&lt;p&gt;批量大小实际上由两件事共同决定：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每卡 batch size&lt;/li&gt;
&lt;li&gt;梯度累积步数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大批量更稳但更吃资源，小批量更细但噪声更大。&lt;/p&gt;
&lt;h2&gt;4. 截断长度（Cutoff Length）&lt;/h2&gt;
&lt;p&gt;这个值直接影响：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;上下文能装多少内容&lt;/li&gt;
&lt;li&gt;显存占用有多大&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最理想的做法通常是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;先统计数据分布，再决定 cutoff，而不是先拍脑袋选一个值。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;5. 验证集比例&lt;/h2&gt;
&lt;p&gt;如果数据量太小，验证集比例设置得再标准也不一定有意义；但如果完全没有验证集，就只能靠训练 loss 猜状态。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250904164230.png&quot; alt=&quot;验证集曲线示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;所以这部分没有绝对标准，关键是：样本量要足够让验证集真的能“说明问题”。&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 核心能力 03：Streaming 与 v2 事件格式</title><link>https://owen571.top/posts/study/langgraph/04-langgraph-%E6%B5%81%E5%BC%8F%E8%BE%93%E5%87%BA-streaming-%E4%B8%8E-v2-%E4%BA%8B%E4%BB%B6/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/04-langgraph-%E6%B5%81%E5%BC%8F%E8%BE%93%E5%87%BA-streaming-%E4%B8%8E-v2-%E4%BA%8B%E4%BB%B6/</guid><description>把 LangGraph 的流式输出拆成 values、updates、messages、custom 等几种事件，看清 v2 StreamPart 到底统一了什么。</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇建议和 LangChain 的流式输出一起对照着看：LangChain 更偏模型/agent 侧，LangGraph 更偏整张图的运行时事件。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;可结合LangChain的流一起看&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;在入门章节，我们就用到了Graph的stream_mode，提到和agent的有所不同。&lt;/p&gt;
&lt;p&gt;LangGraph 图提供stream（同步）和astream（异步）方法，以迭代器形式生成流式输出。传入一个或多个流模式来控制接收的数据内容。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for chunk in graph.stream(
    {&quot;topic&quot;: &quot;ice cream&quot;},
    stream_mode=[&quot;updates&quot;, &quot;custom&quot;],
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;updates&quot;:
        for node_name, state in chunk[&quot;data&quot;].items():
            print(f&quot;Node {node_name} updated: {state}&quot;)
    elif chunk[&quot;type&quot;] == &quot;custom&quot;:
        print(f&quot;Status: {chunk[&apos;data&apos;][&apos;status&apos;]}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Status: thinking of a joke...
Node generate_joke updated: {&apos;joke&apos;: &apos;Why did the ice cream go to school? To get a sundae education!&apos;}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 流输出格式 (v2)&lt;/h2&gt;
&lt;h3&gt;(1) stream mode&lt;/h3&gt;
&lt;p&gt;向version=&quot;v2&quot;传入stream()或astream()以获取统一的输出格式。每个数据块均为一个StreamPart字典，具有固定结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    &quot;type&quot;: &quot;values&quot; | &quot;updates&quot; | &quot;messages&quot; | &quot;custom&quot; | &quot;checkpoints&quot; | &quot;tasks&quot; | &quot;debug&quot;,
    &quot;ns&quot;: (),           # namespace tuple, populated for subgraph events
    &quot;data&quot;: ...,        # the actual payload (type varies by stream mode)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每种流模式都有对应的TypedDict，包含ValuesStreamPart、UpdatesStreamPart、MessagesStreamPart、CustomStreamPart、CheckpointStreamPart、TasksStreamPart、DebugStreamPart（对应7种streammode）&lt;/p&gt;
&lt;p&gt;在 v1 版本（默认）中，输出格式会根据你的流式传输选项而变化（单模式返回原始数据，多模式返回(mode, data) 元组，子图返回(namespace, data) 元组）。在 v2 版本中，格式始终保持一致。&lt;/p&gt;
&lt;p&gt;可以看到，这里的v1、v2区别，实际和LangChain Agent的模式选择一样，v2都是有更格式化的输出（即StreamPart）。当时提到但是还不够详细，这里细致拆解一下StreamPart：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;type=&quot;values&quot;：每一步后的完整状态快照；data 是完整 state（full state）。&lt;/li&gt;
&lt;li&gt;type=&quot;updates&quot;：节点执行后对 state 的增量更新；data 形如 {&quot;node_name&quot;: {&quot;changed_key&quot;: value}}。&lt;/li&gt;
&lt;li&gt;type=&quot;messages&quot;：LLM 消息流；data 通常是 (message_chunk, metadata)。&lt;/li&gt;
&lt;li&gt;type=&quot;custom&quot;：来自 get_stream_writer() 主动写出的自定义事件；data 就是 writer({...}) 传入的内容。&lt;/li&gt;
&lt;li&gt;type=&quot;checkpoints&quot;：checkpoint 事件流；data 是检查点快照信息（类似 get_state 返回结构）。&lt;/li&gt;
&lt;li&gt;type=&quot;tasks&quot;：任务生命周期事件（开始/结束/结果/错误）；data 是任务执行信息。&lt;/li&gt;
&lt;li&gt;type=&quot;debug&quot;：最全量调试事件；data 包含更完整的执行上下文与诊断信息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面，我放一个最小的message用法示例，它定义了图状态，用stream执行图，然后实现了逐token输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass

from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START


@dataclass
class MyState:
    topic: str
    joke: str = &quot;&quot;


model = init_chat_model(model=&quot;gpt-4.1-mini&quot;)

def call_model(state: MyState):
    &quot;&quot;&quot;Call the LLM to generate a joke about a topic&quot;&quot;&quot;
    # Note that message events are emitted even when the LLM is run using .invoke rather than .stream
    model_response = model.invoke(
        [
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Generate a joke about {state.topic}&quot;}
        ]
    )
    return {&quot;joke&quot;: model_response.content}

graph = (
    StateGraph(MyState)
    .add_node(call_model)
    .add_edge(START, &quot;call_model&quot;)
    .compile()
)

# The &quot;messages&quot; stream mode streams LLM tokens with metadata
# Use version=&quot;v2&quot; for a unified StreamPart format
for chunk in graph.stream(
    {&quot;topic&quot;: &quot;ice cream&quot;},
    stream_mode=&quot;messages&quot;,
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;messages&quot;:
        message_chunk, metadata = chunk[&quot;data&quot;]
        if message_chunk.content:
            print(message_chunk.content, end=&quot;|&quot;, flush=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-6.CC2Cb4h9.png&amp;amp;w=1462&amp;amp;h=296&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;至于ns 是事件来源的命名空间路径，用来标识这个 stream chunk 来自哪一层图。比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ns == ()：来自主图（root graph）&lt;/li&gt;
&lt;li&gt;ns == (&quot;node_2:&amp;lt;task_id&amp;gt;&quot;,)：来自 node_2 调用的子图&lt;/li&gt;
&lt;li&gt;ns == (&quot;child:&amp;lt;id&amp;gt;&quot;, &quot;child_1:&amp;lt;id&amp;gt;&quot;)：来自更深层嵌套子图&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们可以通过 chunk[&quot;type&quot;] 过滤数据块，并获得正确的负载类型。每个分支都会将 part[&quot;data&quot;] 收窄为对应模式的特定类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for part in graph.stream(
    {&quot;topic&quot;: &quot;ice cream&quot;},
    stream_mode=[&quot;values&quot;, &quot;updates&quot;, &quot;messages&quot;, &quot;custom&quot;],
    version=&quot;v2&quot;,
):
    if part[&quot;type&quot;] == &quot;values&quot;:
        # ValuesStreamPart — full state snapshot after each step
        print(f&quot;State: topic={part[&apos;data&apos;][&apos;topic&apos;]}&quot;)
    elif part[&quot;type&quot;] == &quot;updates&quot;:
        # UpdatesStreamPart — only the changed keys from each node
        for node_name, state in part[&quot;data&quot;].items():
            print(f&quot;Node `{node_name}` updated: {state}&quot;)
    elif part[&quot;type&quot;] == &quot;messages&quot;:
        # MessagesStreamPart — (message_chunk, metadata) from LLM calls
        msg, metadata = part[&quot;data&quot;]
        print(msg.content, end=&quot;&quot;, flush=True)
    elif part[&quot;type&quot;] == &quot;custom&quot;:
        # CustomStreamPart — arbitrary data from get_stream_writer()
        print(f&quot;Progress: {part[&apos;data&apos;][&apos;progress&apos;]}%&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;(2) 过滤&lt;/h2&gt;
&lt;p&gt;我们之前在LangChain核心组件Models中就学过，init_chat_model的时候用config参数，添加额外字典，从而对运行时的行为控制。&lt;/p&gt;
&lt;p&gt;不过这里是不同的层级，直接在init_model_model里面加入tags，是专门给模型实例设置默认标签，每次调用都会带上。我们可以通过这个元信息，直接过滤：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model

# model_1 is tagged with &quot;joke&quot;
model_1 = init_chat_model(model=&quot;gpt-4.1-mini&quot;, tags=[&apos;joke&apos;])
# model_2 is tagged with &quot;poem&quot;
model_2 = init_chat_model(model=&quot;gpt-4.1-mini&quot;, tags=[&apos;poem&apos;])

graph = ... # define a graph that uses these LLMs

# The stream_mode is set to &quot;messages&quot; to stream LLM tokens
# The metadata contains information about the LLM invocation, including the tags
async for chunk in graph.astream(
    {&quot;topic&quot;: &quot;cats&quot;},
    stream_mode=&quot;messages&quot;,
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;messages&quot;:
        msg, metadata = chunk[&quot;data&quot;]
        # Filter the streamed tokens by the tags field in the metadata to only include
        # the tokens from the LLM invocation with the &quot;joke&quot; tag
        if metadata[&quot;tags&quot;] == [&quot;joke&quot;]:
            print(msg.content, end=&quot;|&quot;, flush=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者，我们还可以按照node name过滤，或者按照自定义的字段过滤……总之，就是简单的python逻辑。&lt;/p&gt;
&lt;h2&gt;(3) nostream&lt;/h2&gt;
&lt;p&gt;使用 nostream 标签可将大语言模型的输出完全排除在流式传输之外。标记为 nostream 的调用仍会正常执行并生成输出，只是其词元不会在 messages 模式下发送。(这里nostream是写在config字段下面的)&lt;/p&gt;
&lt;p&gt;该功能适用于以下场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要大语言模型输出用于内部处理（例如结构化输出），但不希望将其流式传输给客户端&lt;/li&gt;
&lt;li&gt;通过其他通道（例如自定义界面消息）流式传输相同内容，且希望避免 messages 流中出现重复输出&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;举例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Any, TypedDict

from langchain_anthropic import ChatAnthropic
from langgraph.graph import START, StateGraph

stream_model = ChatAnthropic(model_name=&quot;claude-3-haiku-20240307&quot;)
internal_model = ChatAnthropic(model_name=&quot;claude-3-haiku-20240307&quot;).with_config(
    {&quot;tags&quot;: [&quot;nostream&quot;]}
)


class State(TypedDict):
    topic: str
    answer: str
    notes: str


def answer(state: State) -&amp;gt; dict[str, Any]:
    r = stream_model.invoke(
        [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Reply briefly about {state[&apos;topic&apos;]}&quot;}]
    )
    return {&quot;answer&quot;: r.content}


def internal_notes(state: State) -&amp;gt; dict[str, Any]:
    # Tokens from this model are omitted from stream_mode=&quot;messages&quot; because of nostream
    r = internal_model.invoke(
        [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Private notes on {state[&apos;topic&apos;]}&quot;}]
    )
    return {&quot;notes&quot;: r.content}


graph = (
    StateGraph(State)
    .add_node(&quot;write_answer&quot;, answer)
    .add_node(&quot;internal_notes&quot;, internal_notes)
    .add_edge(START, &quot;write_answer&quot;)
    .add_edge(&quot;write_answer&quot;, &quot;internal_notes&quot;)
    .compile()
)

initial_state: State = {&quot;topic&quot;: &quot;AI&quot;, &quot;answer&quot;: &quot;&quot;, &quot;notes&quot;: &quot;&quot;}
stream = graph.stream(initial_state, stream_mode=&quot;messages&quot;)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>FastAPI 请求体：Pydantic 模型、多参数与嵌套结构</title><link>https://owen571.top/posts/study/fastapi/03-fastapi-%E8%AF%B7%E6%B1%82%E4%BD%93%E4%B8%8E-pydantic-%E6%A8%A1%E5%9E%8B/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/03-fastapi-%E8%AF%B7%E6%B1%82%E4%BD%93%E4%B8%8E-pydantic-%E6%A8%A1%E5%9E%8B/</guid><description>当输入不再只是 URL 参数，而是一整个 JSON 请求体时，FastAPI 如何借助 Pydantic 做解析、校验、嵌套和文档生成。</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;从这一篇开始，输入不再只是 URL 上的几个值，而是成块的数据结构。FastAPI 的优势也从这里开始明显：不是自己手写 JSON 解析，而是直接把结构声明成模型。&lt;/p&gt;
&lt;h2&gt;1. 用 Pydantic 模型声明请求体&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from pydantic import BaseModel


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None


app = FastAPI()


@app.post(&quot;/item/&quot;)
async def create_item(item: Item):
    item_dict = item.model_dump()
    if item.tax is not None:
        price_with_tax = item.price + item.tax
        item_dict.update({&quot;price_with_tax&quot;: price_with_tax})
    return item_dict
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的关键不是“能收到 JSON”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FastAPI 会把请求体按 &lt;code&gt;Item&lt;/code&gt; 模型解析&lt;/li&gt;
&lt;li&gt;Pydantic 会自动校验字段类型&lt;/li&gt;
&lt;li&gt;文档会自动生成 schema&lt;/li&gt;
&lt;li&gt;校验错误会定位到具体字段&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 路径参数、查询参数、请求体可以一起出现&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;@app.put(&quot;/items/{item_id}&quot;)
async def update_item(item_id: int, item: Item, q: str | None = None):
    result = {&quot;item_id&quot;: item_id, **item.model_dump()}
    if q:
        result.update({&quot;q&quot;: q})
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里已经把三类最核心的输入组合起来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;item_id&lt;/code&gt;：路径参数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q&lt;/code&gt;：查询参数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;item&lt;/code&gt;：请求体&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后面很多复杂接口，本质上还是这三层输入的组合。&lt;/p&gt;
&lt;h2&gt;3. 多个请求体参数&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None


class User(BaseModel):
    username: str
    full_name: str | None = None


@app.put(&quot;/items/{item_id}&quot;)
async def update_item(item_id: int, item: Item, user: User):
    return {&quot;item_id&quot;: item_id, &quot;item&quot;: item, &quot;user&quot;: user}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时 FastAPI 期望请求体长成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;item&quot;: {
    &quot;name&quot;: &quot;Foo&quot;,
    &quot;description&quot;: &quot;The pretender&quot;,
    &quot;price&quot;: 42.0,
    &quot;tax&quot;: 3.2
  },
  &quot;user&quot;: {
    &quot;username&quot;: &quot;dave&quot;,
    &quot;full_name&quot;: &quot;Dave Grohl&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 简单类型如果想放进 Body，需要显式声明&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import Body


@app.put(&quot;/b_items/{item_id}&quot;)
async def update_item(
    item_id: int,
    item: Item,
    user: User,
    importance: Annotated[int, Body(gt=0)],
):
    return {&quot;item_id&quot;: item_id, &quot;item&quot;: item, &quot;user&quot;: user, &quot;importance&quot;: importance}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有个很关键的规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pydantic 模型默认会被当成请求体&lt;/li&gt;
&lt;li&gt;简单类型如果不额外声明，默认会被当成查询参数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 &lt;code&gt;Body()&lt;/code&gt; 不只是补校验，它还在明确参数来源。&lt;/p&gt;
&lt;h2&gt;5. 嵌套模型&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel, HttpUrl


class Image(BaseModel):
    url: HttpUrl
    name: str


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None
    tags: set[str] = set()
    images: list[Image] | None = None


@app.put(&quot;/items/{item_id}&quot;)
async def update_item(item_id: int, item: Item):
    return {&quot;item_id&quot;: item_id, &quot;item&quot;: item}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里顺手也能看到 Pydantic 提供的几个常用能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;HttpUrl&lt;/code&gt;：校验 URL&lt;/li&gt;
&lt;li&gt;&lt;code&gt;set[str]&lt;/code&gt;：自动去重&lt;/li&gt;
&lt;li&gt;&lt;code&gt;list[Image]&lt;/code&gt;：子模型列表&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. 给请求体补示例和文档信息&lt;/h2&gt;
&lt;p&gt;模型不仅负责校验，还会直接影响 &lt;code&gt;/docs&lt;/code&gt; 里的展示效果。&lt;/p&gt;
&lt;p&gt;可以在模型里写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None

    model_config = {
        &quot;json_schema_extra&quot;: {
            &quot;examples&quot;: [
                {
                    &quot;name&quot;: &quot;Foo&quot;,
                    &quot;description&quot;: &quot;A very nice Item&quot;,
                    &quot;price&quot;: 35.4,
                    &quot;tax&quot;: 3.2,
                }
            ]
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以在 &lt;code&gt;Body()&lt;/code&gt; 里写 &lt;code&gt;openapi_examples&lt;/code&gt;，这样能给同一个接口准备多个示例场景。&lt;/p&gt;
&lt;h2&gt;7. 请求体这一层真正带来的变化&lt;/h2&gt;
&lt;p&gt;到了这里，FastAPI 的体验已经开始和“手写 Flask 风格的 JSON 解析”拉开差距了。&lt;/p&gt;
&lt;p&gt;你写的不是“接收一个 dict 再自己判空”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先把数据结构声明出来&lt;/li&gt;
&lt;li&gt;再让框架负责解析&lt;/li&gt;
&lt;li&gt;再让文档跟着模型自动长出来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是为什么后面学响应模型、依赖注入和安全时，Pydantic 一直会反复出现。&lt;/p&gt;
</content:encoded></item><item><title>LoRA、QLoRA 与 Qwen2.5-VL：从理论到参数选择</title><link>https://owen571.top/posts/study/fine-tuning/04-lora-qlora-%E4%B8%8E-qwen2-5-vl-%E4%BB%8E%E7%90%86%E8%AE%BA%E5%88%B0%E5%8F%82%E6%95%B0%E9%80%89%E6%8B%A9/</link><guid isPermaLink="true">https://owen571.top/posts/study/fine-tuning/04-lora-qlora-%E4%B8%8E-qwen2-5-vl-%E4%BB%8E%E7%90%86%E8%AE%BA%E5%88%B0%E5%8F%82%E6%95%B0%E9%80%89%E6%8B%A9/</guid><description>先回答 LoRA 为什么可行，再把 QLoRA 和 Qwen2.5-VL 放到同一条理解线上，最后落到几个真正会影响训练结果的超参数上。</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇对应原笔记里临时补上的 LoRA 理论部分。它的价值在于：把“LoRA 好用”从经验结论拉回到一个更能解释的层面。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一、LoRA 为什么可行&lt;/h1&gt;
&lt;p&gt;LoRA 的核心不是“神奇地省参数”，而是它假设：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;模型在适应一个新任务时，真正需要的有效权重变化，往往处于一个相对低维的子空间里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;也就是说，虽然原模型参数很多，但为了适应一个具体任务，未必需要对整个高维权重空间做全量自由调整。&lt;/p&gt;
&lt;h2&gt;1. 从矩阵低秩近似来理解&lt;/h2&gt;
&lt;p&gt;原笔记把这件事讲得很直观：矩阵的信息往往不是均匀分布的，很多维度冗余，主要信息集中在少数方向上。&lt;/p&gt;
&lt;p&gt;如果一个权重矩阵 &lt;code&gt;W&lt;/code&gt; 是 &lt;code&gt;d × d&lt;/code&gt;，全量更新的参数量是 &lt;code&gt;d²&lt;/code&gt;。&lt;br /&gt;
而如果把增量写成两个低秩矩阵的乘积：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ΔW = B × A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;B&lt;/code&gt; 是 &lt;code&gt;d × r&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;A&lt;/code&gt; 是 &lt;code&gt;r × d&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那参数量就从 &lt;code&gt;d²&lt;/code&gt; 变成了 &lt;code&gt;2dr&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;只要 &lt;code&gt;r &amp;lt;&amp;lt; d&lt;/code&gt;，参数量会急剧下降。&lt;/p&gt;
&lt;h2&gt;2. 为什么更新量可以低秩&lt;/h2&gt;
&lt;p&gt;LoRA 论文的核心观察之一，是微调前后权重差值 &lt;code&gt;ΔW&lt;/code&gt; 的主要信息往往集中在少数奇异值上。&lt;/p&gt;
&lt;p&gt;换句话说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;预训练权重 &lt;code&gt;W&lt;/code&gt; 本身可能很复杂&lt;/li&gt;
&lt;li&gt;但“为了适应新任务”产生的变化 &lt;code&gt;ΔW&lt;/code&gt;，通常没有那么高的自由度&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就是 LoRA 低秩假设的经验基础。&lt;/p&gt;
&lt;h1&gt;二、LoRA 的训练过程&lt;/h1&gt;
&lt;h2&gt;1. 初始化&lt;/h2&gt;
&lt;p&gt;LoRA 会冻结原始权重 &lt;code&gt;W&lt;/code&gt;，只训练新增的低秩参数。&lt;/p&gt;
&lt;p&gt;一般做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;W&lt;/code&gt; 不动&lt;/li&gt;
&lt;li&gt;&lt;code&gt;A&lt;/code&gt; 用较小随机值初始化&lt;/li&gt;
&lt;li&gt;&lt;code&gt;B&lt;/code&gt; 初始化为 0&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一开始 &lt;code&gt;ΔW = 0&lt;/code&gt;，不会干扰原模型。&lt;/p&gt;
&lt;h2&gt;2. 更新&lt;/h2&gt;
&lt;p&gt;训练时，模型实际使用的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;W&apos; = W + ΔW = W + B × A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前向传播时先得到预测结果，再根据损失函数对 &lt;code&gt;A&lt;/code&gt; 和 &lt;code&gt;B&lt;/code&gt; 做梯度更新，而 &lt;code&gt;W&lt;/code&gt; 始终冻结。&lt;/p&gt;
&lt;h2&gt;3. 推理&lt;/h2&gt;
&lt;p&gt;训练后有两种常见方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;合并 LoRA 权重后推理&lt;/li&gt;
&lt;li&gt;保持 LoRA 旁路独立，按任务切换 adapter&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这也是 LoRA 很适合多任务管理的原因之一。&lt;/p&gt;
&lt;h1&gt;三、LoRA 在 Transformer 里通常加在哪里&lt;/h1&gt;
&lt;p&gt;LoRA 常见的注入位置是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;注意力层里的 &lt;code&gt;Wq / Wv&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;前馈层里的升维 / 降维线性层&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;它不是任何位置都加，而是优先加在那些最能影响表示能力和生成行为的线性层上。&lt;/p&gt;
&lt;h2&gt;1. 常见示意图&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902163040.png&quot; alt=&quot;LoRA 理论图 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902163106.png&quot; alt=&quot;LoRA 理论图 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902163157.png&quot; alt=&quot;LoRA 理论图 3&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902163220.png&quot; alt=&quot;LoRA 理论图 4&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. 常见经验&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;小模型可以先只加注意力层&lt;/li&gt;
&lt;li&gt;更复杂的生成任务，往往会扩展到 FFN&lt;/li&gt;
&lt;li&gt;并不是参数越多越好，而是要看任务复杂度和数据规模&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;四、QLoRA 是怎么把门槛继续压低的&lt;/h1&gt;
&lt;p&gt;QLoRA 本质上就是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先把基础模型量化到 4-bit&lt;/li&gt;
&lt;li&gt;冻结这些量化权重&lt;/li&gt;
&lt;li&gt;只训练 LoRA 参数&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以它不是“LoRA 的平替”，而是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;量化 + LoRA 的组合方案&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它最大的意义不是理论更漂亮，而是现实里真的能把显存需求拉下来，让更多单机环境也能做实验。&lt;/p&gt;
&lt;h1&gt;五、LoRA 的几个常用超参数&lt;/h1&gt;
&lt;p&gt;原笔记里对 LoRA 相关参数做了简要整理，这里直接沿着那条思路记：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902163530.png&quot; alt=&quot;LoRA 参数图 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902163510.png&quot; alt=&quot;LoRA 参数图 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902163417.png&quot; alt=&quot;LoRA 参数图 3&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;1. &lt;code&gt;rank&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;rank&lt;/code&gt; 决定了低秩更新的表达能力。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;小：参数更省，更新更保守&lt;/li&gt;
&lt;li&gt;大：表达能力更强，但计算和显存开销更高&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;原笔记里的经验是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;小数据集先从 &lt;code&gt;r=8&lt;/code&gt; 或 &lt;code&gt;r=16&lt;/code&gt; 开始&lt;/li&gt;
&lt;li&gt;大数据集或更复杂任务，可以尝试 &lt;code&gt;r=32+&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. &lt;code&gt;alpha&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;alpha&lt;/code&gt; 可以理解成 LoRA 更新量的缩放系数，决定 LoRA 这条旁路影响原模型的强度。&lt;/p&gt;
&lt;h2&gt;3. &lt;code&gt;dropout&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;dropout&lt;/code&gt; 更像是一点正则化，用来缓解小样本过拟合。&lt;/p&gt;
&lt;h1&gt;六、为什么这里顺手补 Qwen2.5-VL&lt;/h1&gt;
&lt;p&gt;因为这组实践最终就是做 Qwen2.5-VL 的多模态微调，所以如果对底座模型完全没有概念，后面的训练参数会显得很抽象。&lt;/p&gt;
&lt;p&gt;Qwen2.5-VL 的特点可以先简单记成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;图像和视频是通过视觉编码器进入模型&lt;/li&gt;
&lt;li&gt;视觉编码结果会和语言解码器对接&lt;/li&gt;
&lt;li&gt;它对文档、表格、公式等复杂视觉内容也有比较强的建模能力&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250902165334.png&quot; alt=&quot;Qwen2.5-VL 结构概览&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这也是为什么它适合后面那类“血迹图像描述 + 细粒度分类”的任务：&lt;br /&gt;
不是因为它天然懂这个领域，而是因为它作为多模态底座，已经具备了图像理解与文本生成的基础能力。&lt;/p&gt;
&lt;h1&gt;七、把理论留到一个足够实用的位置&lt;/h1&gt;
&lt;p&gt;学 LoRA 最容易掉进去的坑，是只记住“它省参数”，但不知道它到底省在哪、为什么能省。&lt;/p&gt;
&lt;p&gt;这篇最重要的目的，其实就是把后面实战里那些参数和结果，提前和一层理论对应上：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;为什么 rank 选小了会保守&lt;/li&gt;
&lt;li&gt;为什么小数据集更容易过拟合&lt;/li&gt;
&lt;li&gt;为什么 QLoRA 会改变显存门槛&lt;/li&gt;
&lt;li&gt;为什么底座模型能力仍然决定最终上限&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>LangGraph 核心能力 04：Interrupt、恢复执行与 Human-in-the-loop</title><link>https://owen571.top/posts/study/langgraph/05-langgraph-%E4%B8%AD%E6%96%AD-interrupt-%E4%B8%8E-human-in-the-loop/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/05-langgraph-%E4%B8%AD%E6%96%AD-interrupt-%E4%B8%8E-human-in-the-loop/</guid><description>把 interrupt 放回真实工作流里看：单中断、多中断、审批流、审核编辑和恢复执行到底分别意味着什么。</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;如果说持久化让图“记得住”，那 &lt;code&gt;interrupt&lt;/code&gt; 则让图第一次真正具备“暂停下来等人类决定再继续”的能力。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;中断功能可让你在指定节点暂停图执行流程，并等待外部输入后再继续运行。这支持需要外部输入才能推进的human-in-the-loop模式。当中断触发时，LangGraph 会通过其持久化层保存图状态，并无限期等待，直到你恢复执行。&lt;/p&gt;
&lt;p&gt;中断的实现方式是在图节点的任意位置调用interrupt()函数。该函数可接收任意可 JSON 序列化的值，并将其暴露给调用方。当你准备继续时，可通过Command重新调用图来恢复执行，该 Command 会成为节点内部interrupt()调用的返回值。&lt;/p&gt;
&lt;p&gt;与静态断点（在特定节点前后暂停）不同，中断是动态的：可置于代码任意位置，并可根据应用逻辑设置条件触发。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;检查点会保留当前状态：检查点写入器会保存完整的图状态，即使处于错误状态，后续也可恢复执行。&lt;/li&gt;
&lt;li&gt;thread_id 是状态指针：设置 config={&quot;configurable&quot;: {&quot;thread_id&quot;: ...}} 告诉检查点加载哪个状态。&lt;/li&gt;
&lt;li&gt;中断载荷通过 chunk[&quot;interrupts&quot;] 暴露：使用 version=&quot;v2&quot; 流式传输时，传入interrupt()的值会出现在values流片段的interrupts字段中，便于知晓图正在等待什么。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;选择thread_id本质上是持久化游标。重复使用可恢复同一检查点；使用新值则会以空状态启动全新线程。&lt;/p&gt;
&lt;h2&gt;1. 用interrupt暂停&lt;/h2&gt;
&lt;p&gt;interrupt函数会暂停图执行并向调用方返回一个值。在节点内调用interrupt时，LangGraph 会保存当前图状态，并等待你通过输入恢复执行。&lt;/p&gt;
&lt;p&gt;使用interrupt需要满足：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个检查点存储器用于持久化图状态（生产环境请使用持久化检查点存储器）&lt;/li&gt;
&lt;li&gt;配置中包含线程 ID，使运行时知道从哪个状态恢复&lt;/li&gt;
&lt;li&gt;在需要暂停的位置调用interrupt()（负载必须可 JSON 序列化）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;调用interrupt时，会发生以下过程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;图执行会在调用interrupt的精确位置暂停&lt;/li&gt;
&lt;li&gt;状态会通过检查点保存，以便后续恢复执行；生产环境中应使用持久化检查点（如基于数据库实现）&lt;/li&gt;
&lt;li&gt;返回值会以__interrupt__标识返回给调用方；该值可以是任意可 JSON 序列化类型（字符串、对象、数组等）&lt;/li&gt;
&lt;li&gt;图将无限期等待，直到你通过响应恢复执行&lt;/li&gt;
&lt;li&gt;恢复时响应会传回节点，并成为interrupt()调用的返回值&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面有一个简单的动作批准函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import TypedDict

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt


class State(TypedDict):
    approved: bool


def approval_node(state: State):
    # 运行到这里会暂停，并把这段提示抛给调用方
    approved = interrupt(&quot;Do you approve this action?&quot;)
    # 恢复时，Command(resume=...) 传入的值会回到 approved
    return {&quot;approved&quot;: approved}


checkpointer = MemorySaver()
graph = (
    StateGraph(State)
    .add_node(&quot;approval&quot;, approval_node)
    .add_edge(START, &quot;approval&quot;)
    .add_edge(&quot;approval&quot;, END)
    .compile(checkpointer=checkpointer)
)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;approval-demo&quot;}}

# 第一次执行：会在 interrupt() 处暂停
result = graph.invoke({&quot;approved&quot;: False}, config)
interrupts = result.get(&quot;__interrupt__&quot;, ())

if interrupts:
    question = interrupts[0].value
    user_text = input(f&quot;{question} (y/n): &quot;).strip().lower()
    approved = user_text in {&quot;y&quot;, &quot;yes&quot;, &quot;true&quot;, &quot;1&quot;}

    # 第二次执行：用 Command(resume=...) 恢复
    result = graph.invoke(Command(resume=approved), config)
    print(&quot;Final state:&quot;, result)
else:
    print(&quot;No interrupt happened:&quot;, result)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这其中，result的真实格式如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;approved&quot;: False,
  &quot;__interrupt__&quot;: [
    Interrupt(
      value=&quot;Do you approve this action?&quot;,
      id=&quot;7e5f3e800a66e12f26f09eca9a35ac50&quot;
    )
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们知道，原始invoke一个节点的时候，会返回整个state，其中我们关注的比较多的是包含AIMessage/ToolMessage/HumanMessage等的state[&quot;messages&quot;]通道，但是这个state里面还有我们自己定义的approved:bool，所以也会被放在state中供我们查阅和更新。&lt;/p&gt;
&lt;p&gt;最终效果如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-7.BVJZr2Wk.png&amp;amp;w=1018&amp;amp;h=248&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. 常见工作模式&lt;/h2&gt;
&lt;p&gt;中断机制的核心价值在于能够暂停执行流程并等待外部输入。这一特性适用于多种应用场景，包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;审批工作流：执行关键操作（API 调用、数据库修改、金融交易）前暂停&lt;/li&gt;
&lt;li&gt;处理多中断：单次调用中恢复多个中断时，将中断 ID 与恢复值配对&lt;/li&gt;
&lt;li&gt;审核与编辑：允许人工在继续执行前审核并修改大模型输出或工具调用&lt;/li&gt;
&lt;li&gt;中断工具调用：执行工具调用前暂停，以便审核和编辑工具调用内容&lt;/li&gt;
&lt;li&gt;验证人工输入：进入下一步前暂停，以验证人工输入&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.1 审批工作流&lt;/h3&gt;
&lt;p&gt;这是最常见的用法：在执行关键动作前先暂停，把动作详情抛给人工，人工批准后再继续，不批准则走取消分支。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Literal, Optional
from typing_extensions import TypedDict

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt


class ApprovalState(TypedDict):
    action_details: str
    status: Optional[Literal[&quot;pending&quot;, &quot;approved&quot;, &quot;rejected&quot;]]


def approval_node(state: ApprovalState) -&amp;gt; Command[Literal[&quot;proceed&quot;, &quot;cancel&quot;]]:
    decision = interrupt({
        &quot;question&quot;: &quot;Approve this action?&quot;,
        &quot;details&quot;: state[&quot;action_details&quot;],
    })
    return Command(goto=&quot;proceed&quot; if decision else &quot;cancel&quot;)


def proceed_node(state: ApprovalState):
    return {&quot;status&quot;: &quot;approved&quot;}


def cancel_node(state: ApprovalState):
    return {&quot;status&quot;: &quot;rejected&quot;}


graph = (
    StateGraph(ApprovalState)
    .add_node(&quot;approval&quot;, approval_node)
    .add_node(&quot;proceed&quot;, proceed_node)
    .add_node(&quot;cancel&quot;, cancel_node)
    .add_edge(START, &quot;approval&quot;)
    .add_edge(&quot;proceed&quot;, END)
    .add_edge(&quot;cancel&quot;, END)
    .compile(checkpointer=MemorySaver())
)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;approval-1&quot;}}

first = graph.invoke(
    {&quot;action_details&quot;: &quot;Transfer $500&quot;, &quot;status&quot;: &quot;pending&quot;},
    config=config,
)
print(first[&quot;__interrupt__&quot;])

final = graph.invoke(Command(resume=True), config=config)
print(final)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 &lt;code&gt;Command(resume=True)&lt;/code&gt; 表示批准，&lt;code&gt;Command(resume=False)&lt;/code&gt; 表示拒绝。节点恢复后，会根据这个布尔值决定跳转到哪个节点。&lt;/p&gt;
&lt;h3&gt;2.2 处理多中断&lt;/h3&gt;
&lt;p&gt;当图里有并行分支，而且多个分支同时执行到 &lt;code&gt;interrupt()&lt;/code&gt; 时，一次运行里可能会返回多个中断。此时恢复时不能只传一个值，而是要把每个中断的 &lt;code&gt;id&lt;/code&gt; 和它对应的恢复值配对起来。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from typing_extensions import TypedDict
import operator

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, END, StateGraph
from langgraph.types import Command, interrupt


class State(TypedDict):
    vals: Annotated[list[str], operator.add]


def node_a(state: State):
    answer = interrupt(&quot;question_a&quot;)
    return {&quot;vals&quot;: [f&quot;a:{answer}&quot;]}


def node_b(state: State):
    answer = interrupt(&quot;question_b&quot;)
    return {&quot;vals&quot;: [f&quot;b:{answer}&quot;]}


graph = (
    StateGraph(State)
    .add_node(&quot;a&quot;, node_a)
    .add_node(&quot;b&quot;, node_b)
    .add_edge(START, &quot;a&quot;)
    .add_edge(START, &quot;b&quot;)
    .add_edge(&quot;a&quot;, END)
    .add_edge(&quot;b&quot;, END)
    .compile(checkpointer=InMemorySaver())
)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;multi-interrupt-1&quot;}}

first = graph.invoke({&quot;vals&quot;: []}, config=config)
interrupts = first[&quot;__interrupt__&quot;]

resume_map = {
    interrupt_obj.id: f&quot;answer for {interrupt_obj.value}&quot;
    for interrupt_obj in interrupts
}

final = graph.invoke(Command(resume=resume_map), config=config)
print(final)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单个中断时，&lt;code&gt;resume&lt;/code&gt; 可以直接传一个值&lt;/li&gt;
&lt;li&gt;多个中断时，&lt;code&gt;resume&lt;/code&gt; 应该传一个字典：&lt;code&gt;{interrupt_id: resume_value}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.3 审核与编辑&lt;/h3&gt;
&lt;p&gt;这种模式不是简单地“同意/拒绝”，而是把当前状态中的某一部分内容交给人工修改，然后把修改后的内容写回 state。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import TypedDict

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt


class ReviewState(TypedDict):
    generated_text: str


def review_node(state: ReviewState):
    updated = interrupt({
        &quot;instruction&quot;: &quot;Review and edit this content&quot;,
        &quot;content&quot;: state[&quot;generated_text&quot;],
    })
    return {&quot;generated_text&quot;: updated}


graph = (
    StateGraph(ReviewState)
    .add_node(&quot;review&quot;, review_node)
    .add_edge(START, &quot;review&quot;)
    .add_edge(&quot;review&quot;, END)
    .compile(checkpointer=MemorySaver())
)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;review-1&quot;}}

first = graph.invoke({&quot;generated_text&quot;: &quot;Initial draft&quot;}, config=config)
print(first[&quot;__interrupt__&quot;])

final = graph.invoke(
    Command(resume=&quot;Improved draft after review&quot;),
    config=config,
)
print(final)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里恢复时传入的不是布尔值，而是“人工编辑后的最终文本”。这个值会直接成为 &lt;code&gt;interrupt()&lt;/code&gt; 的返回值，再被写回 &lt;code&gt;generated_text&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;2.4 中断工具调用&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;interrupt()&lt;/code&gt; 不一定只能写在普通节点里，也可以直接写在工具函数里。这样当模型调用这个工具时，工具会先暂停，等待人工审批或修改参数，之后才真正执行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain_core.tools import tool
from langgraph.types import interrupt


@tool
def send_email(to: str, subject: str, body: str):
    &quot;&quot;&quot;Send an email to a recipient.&quot;&quot;&quot;
    response = interrupt({
        &quot;action&quot;: &quot;send_email&quot;,
        &quot;to&quot;: to,
        &quot;subject&quot;: subject,
        &quot;body&quot;: body,
        &quot;message&quot;: &quot;Approve sending this email?&quot;,
    })

    if response.get(&quot;action&quot;) == &quot;approve&quot;:
        final_to = response.get(&quot;to&quot;, to)
        final_subject = response.get(&quot;subject&quot;, subject)
        final_body = response.get(&quot;body&quot;, body)
        return f&quot;Email sent to {final_to} with subject &apos;{final_subject}&apos;&quot;

    return &quot;Email cancelled by user&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式常见于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发邮件&lt;/li&gt;
&lt;li&gt;调外部 API&lt;/li&gt;
&lt;li&gt;写数据库&lt;/li&gt;
&lt;li&gt;下单、转账、删除记录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，人工不仅可以批准，还可以顺手修改工具参数，然后再继续执行。&lt;/p&gt;
&lt;h3&gt;2.5 验证人工输入&lt;/h3&gt;
&lt;p&gt;有些场景下，人工输入本身可能不合法。这时可以在同一个节点里循环调用 &lt;code&gt;interrupt()&lt;/code&gt;，直到拿到合法输入为止。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.types import interrupt


def get_age_node(state):
    prompt = &quot;What is your age?&quot;

    while True:
        answer = interrupt(prompt)

        if isinstance(answer, int) and answer &amp;gt; 0:
            break
        else:
            prompt = f&quot;&apos;{answer}&apos; is not a valid age. Please enter a positive number.&quot;

    return {&quot;age&quot;: answer}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用方式如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一次暂停，提示 &lt;code&gt;&quot;What is your age?&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果恢复时传入 &lt;code&gt;&quot;thirty&quot;&lt;/code&gt;，校验不通过，会再次 &lt;code&gt;interrupt()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;再恢复时传入 &lt;code&gt;30&lt;/code&gt;，校验通过，节点才真正返回&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一模式很适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表单采集&lt;/li&gt;
&lt;li&gt;参数确认&lt;/li&gt;
&lt;li&gt;需要严格类型或格式的人工输入&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>PyTorch 线性回归：梯度下降与训练四步</title><link>https://owen571.top/posts/study/pytorch/01-pytorch-%E7%BA%BF%E6%80%A7%E5%9B%9E%E5%BD%92-%E6%A2%AF%E5%BA%A6%E4%B8%8B%E9%99%8D%E4%B8%8E%E8%AE%AD%E7%BB%83%E5%9B%9B%E6%AD%A5/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/01-pytorch-%E7%BA%BF%E6%80%A7%E5%9B%9E%E5%BD%92-%E6%A2%AF%E5%BA%A6%E4%B8%8B%E9%99%8D%E4%B8%8E%E8%AE%AD%E7%BB%83%E5%9B%9B%E6%AD%A5/</guid><description>从最简单的线性回归开始，把 PyTorch 训练模型的四步走清楚：数据、模型、损失函数和优化器。</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇主要整理自 &lt;code&gt;liuer_pytorch/1-4.ipynb&lt;/code&gt;，以及我自己写过的 &lt;code&gt;pytorch_learning/pytorch_1.py&lt;/code&gt;、&lt;code&gt;pytorch_learning/pytorch_5.py&lt;/code&gt;。它们共同在做一件事：用一个最简单的任务，把“训练神经网络”这件事拆开。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 为什么从线性回归开始&lt;/h2&gt;
&lt;p&gt;我越来越觉得，PyTorch 的入门最好不要一上来就卷 CNN 或 Transformer。&lt;br /&gt;
线性回归虽然简单，但它几乎把训练流程里所有最基础的东西都露出来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据长什么样&lt;/li&gt;
&lt;li&gt;模型参数是什么&lt;/li&gt;
&lt;li&gt;损失函数在优化什么&lt;/li&gt;
&lt;li&gt;梯度下降到底怎么更新参数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;很多后面更复杂的网络，其实都只是把这个流程换成了更复杂的函数。&lt;/p&gt;
&lt;h2&gt;2. 训练模型的四步&lt;/h2&gt;
&lt;p&gt;在课程笔记里，这条线很明确：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;准备数据集&lt;/li&gt;
&lt;li&gt;设计模型&lt;/li&gt;
&lt;li&gt;设计损失函数和优化器&lt;/li&gt;
&lt;li&gt;写训练循环&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这四步几乎可以当成 PyTorch 的最小心智模型。后面不管做分类、卷积还是序列任务，都还是这四步。&lt;/p&gt;
&lt;h2&gt;3. 先用 NumPy 直觉理解梯度下降&lt;/h2&gt;
&lt;p&gt;在线性回归里，我们希望学到的关系是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;y = wx + b
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果用均方误差：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MSE = 1 / N * Σ (ŷ - y)^2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么训练本质上就是不断调整 &lt;code&gt;w&lt;/code&gt; 和 &lt;code&gt;b&lt;/code&gt;，让误差越来越小。&lt;br /&gt;
课程里也提到了梯度下降、随机梯度下降，以及 mini-batch 为什么是一个工程上的折中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;整批数据一起算，稳定但更新慢&lt;/li&gt;
&lt;li&gt;单样本更新，噪声更大但更容易跳出局部坏区域&lt;/li&gt;
&lt;li&gt;mini-batch 在两者之间做 trade-off&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一层如果没想明白，后面 &lt;code&gt;optimizer.step()&lt;/code&gt; 很容易变成一句纯咒语。&lt;/p&gt;
&lt;h2&gt;4. 用 PyTorch 写一个最小线性回归&lt;/h2&gt;
&lt;p&gt;下面这段代码基本就是我当时练手时的核心版本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import numpy as np
import torch
import torch.nn as nn

x = np.arange(1, 12, dtype=np.float32).reshape(-1, 1)
y = 2 * x + 3


class LinearRegressionModel(nn.Module):
    def __init__(self, input_dim, output_dim):
        super().__init__()
        self.linear = nn.Linear(input_dim, output_dim)

    def forward(self, inp):
        return self.linear(inp)


model = LinearRegressionModel(1, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
criterion = nn.MSELoss()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最值得记住的是两点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nn.Linear&lt;/code&gt; 已经把 &lt;code&gt;wx + b&lt;/code&gt; 封装好了&lt;/li&gt;
&lt;li&gt;&lt;code&gt;model.parameters()&lt;/code&gt; 会把可学习参数交给优化器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正的训练循环则是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for epoch in range(1000):
    inputs = torch.from_numpy(x)
    labels = torch.from_numpy(y)

    optimizer.zero_grad()
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里每一行都对应一个明确动作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;zero_grad()&lt;/code&gt;：清空上一次的梯度&lt;/li&gt;
&lt;li&gt;&lt;code&gt;model(inputs)&lt;/code&gt;：前向传播&lt;/li&gt;
&lt;li&gt;&lt;code&gt;criterion(...)&lt;/code&gt;：得到损失&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loss.backward()&lt;/code&gt;：反向传播算梯度&lt;/li&gt;
&lt;li&gt;&lt;code&gt;optimizer.step()&lt;/code&gt;：更新参数&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. 为什么这一套值得反复记&lt;/h2&gt;
&lt;p&gt;我自己后来再看 CNN、RNN、Transformer 时，会发现很多“新东西”其实只是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据形状变了&lt;/li&gt;
&lt;li&gt;模型结构变复杂了&lt;/li&gt;
&lt;li&gt;损失函数换了&lt;/li&gt;
&lt;li&gt;优化器可能从 SGD 变成 Adam&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但训练主线没有变。&lt;/p&gt;
&lt;p&gt;所以在 PyTorch 入门阶段，最值钱的不是“背了多少层名字”，而是把下面这个模板吃透：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for batch in dataloader:
    optimizer.zero_grad()
    pred = model(batch_x)
    loss = criterion(pred, batch_y)
    loss.backward()
    optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 这一阶段该记住什么&lt;/h2&gt;
&lt;p&gt;如果只保留最少的几句话，我会记：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;线性回归不是为了学回归，而是为了学训练流程。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nn.Module + loss + optimizer + loop&lt;/code&gt; 是 PyTorch 最核心的训练骨架。&lt;/li&gt;
&lt;li&gt;梯度下降不是黑盒，它只是在沿着损失下降的方向调整参数。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;有了这一层，后面看 Tensor、Autograd 和更复杂网络时，就不容易失去主线。&lt;/p&gt;
</content:encoded></item><item><title>强化学习入门：为什么需要 RL、术语与 MDP</title><link>https://owen571.top/posts/study/reinforce-learning/01-%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0-%E5%BC%95%E5%85%A5%E6%9C%AF%E8%AF%AD%E4%B8%8Emdp%E5%9F%BA%E7%A1%80/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/01-%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0-%E5%BC%95%E5%85%A5%E6%9C%AF%E8%AF%AD%E4%B8%8Emdp%E5%9F%BA%E7%A1%80/</guid><description>从对齐鸿沟切入，先建立强化学习的基本术语、MRP / MDP 与 Bellman 视角。</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;在笔记一中, 我们将沿着RL建模路线, 一步一步得到现在通用的Agent+RL的算法基础.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agent经过了很长时间的探索和发展, 做出过很多方面的探索. 想要一次对Agent进行完整的探索显然是不可能做到的, 因此本文主要参照其中一条线路 --&lt;strong&gt;大语言模型（LLM）与强化学习（RL）结合领域&lt;/strong&gt;, 梳理其发展, 学习其中的关键论文和思想.&lt;/p&gt;
&lt;p&gt;实质上, LLM结合RL的方向, 是为了让模型从&quot;死记硬背&quot;的文本生成器, 转变会思考, 能行动, 能与环境交互的自主Agent. 而这其中一个重要的概念就是对齐.&lt;/p&gt;
&lt;h1&gt;一. 鸿沟 -- 为什么需要RL ?&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;强化学习 ( reinforcement learning, RL)&lt;/strong&gt; 作为机器学习的重要分支, 讨论的是智能体 (agent) 怎么在复杂、不确定的环境中 (environment) 里去最大化它所能获得的奖励.&lt;/p&gt;
&lt;p&gt;强化学习是除监督学习和非监督学习之外的第三种基本的机器学习方法, 它不需要带标签的输入输出, 也不需要对非最优解精准的纠正, 而是通过智能体和环境不断交互, 尽可能从环境中获取奖励. 其示意图如下:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026111316.C5fUkhqy.png&amp;amp;w=1114&amp;amp;h=490&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;1. 意图理解鸿沟&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;监督微调（SFT）&lt;/strong&gt;, 可以说是整个LLM训练的起点. 当时的训练, 包括GPT和BERT, 不管是few-shot, zero-shot还是什么, 都是通过在高质量的问答数据上进行微调, 让模型学会&quot; 如何回答指令&quot;. 这一点同样延伸到了后面对于Agent的训练, 似乎已经成为人机交互惯式.&lt;/p&gt;
&lt;p&gt;然而, 纯SFT有一个根本性的局限: 它无法解决&lt;strong&gt;对齐 ( Alignment )&lt;/strong&gt; 鸿沟. 在这里, 要简单介绍一下对齐的这个概念. 这里说的对齐, 实际上就是&lt;strong&gt;让大模型语言的行为, 输出和决策方式与其设计者 ( 人类操作者 ) 的意图, 价值观和指令保持一致的过程&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251018201115.C8lMoNJO.png&amp;amp;w=2170&amp;amp;h=874&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;简而言之, 就是让大模型更像人, 向人&quot;对齐&quot;, 做到听懂人话, 价值观正向, 诚实可信, 实用主义等等.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;我忽然想到了之前看到过的一个新闻, 年轻的程序员因为研究问题每天花大量时间跟AI(ChatGPT)对话, 随着对话深入他渐渐认为自己是有某种&quot;重大使命&quot;, 甚至认为一切都是虚构的, 而AI面对某些疯狂的幻想, 总是会给予鼓励的态度.孤立, 压力, 药物使用和缺乏睡眠, 本来就可能引发精神妄想，而AI对话往往会进一步加剧这一过程, 最终把自己送进了精神病院. 而在北美, 这种现象也不是个例, 感兴趣自行搜寻.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为什么SFT就很难实现这个愿景 ? 其中一个重要原因就是, SFT的本质是&quot;模仿学习&quot;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;同一个开放式问题, 有多个正确但风格不同的回答, 但是SFT并没有判断哪一个更好的能力.&lt;/li&gt;
&lt;li&gt;SFT模型无法进行复杂的价值衡量, 结果充满不确定性, 且容易受到context干扰 ( LLM刚兴起的时间, 网络上有大量用户利用上下文的干扰, 让AI输出NSFW甚至违背人类价值观的内容, 黑话称&quot;破限&quot;)&lt;/li&gt;
&lt;li&gt;对人类潜在或深层意图的理解不够 ( 比如提问&quot;希特勒有哪些煽动性的演讲技巧 ?&quot; SFT表面可能理解为简单的论文演讲技巧学习, 去收集资料详细罗列其手法, 而不加入批判和风险提示. 所以部分有邪恶目的的人, 利用AI的理解鸿沟也可以获取自己想要的信息 )&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;2. 能力鸿沟&lt;/h2&gt;
&lt;p&gt;如前文所论述, SFT本质上是模仿学习, 高度依赖于人类给定的数据集. 但是这些数据集也是来源于人类的, 所以说, SFT的最高上限就是人类的能力了. 但是, 如果使用强化学习, 让
智能体自己在环境中探索, &lt;strong&gt;有非常大大潜力, 它可以获得超越人类的表现&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;比如早年名声大噪的AlphaGo击败顶级人类棋手.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;3. 场景鸿沟&lt;/h2&gt;
&lt;p&gt;除了同一场景学习效果上的差别, 光是SFT, 还存在一些无法满足的场景, 或者说硬伤. 我们在监督学习中, 有两个最基本的假设:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;输入的数据(标注的数据) 都应该是没有关联的, 或着说样本之间应该是&lt;strong&gt;独立同分布&lt;/strong&gt;的. 否则, 学习器将不好学习.&lt;/li&gt;
&lt;li&gt;我们必须告诉学习器正确的标签.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;但是一些情况下, 这两个条件都是不可满足. 设想这样一个场景, 我们要学习Pong游戏的玩法, 但是游戏的画面帧与帧之间是相关的时间序列数据, 并且, 决策没有获得反馈, 游戏没法知道哪个动作是“正确动作”. 但是, 我们依然希望智能体能够学习, 这就需要用到强化学习.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026110346.D5Ur9fuW.png&amp;amp;w=676&amp;amp;h=462&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. 强化学习的特征与历史&lt;/h2&gt;
&lt;p&gt;我们可以总结一些强化学习的特征如下:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;强化学习会进行试错探索, 它通过探索环境来获取对环境的一些理解.&lt;/li&gt;
&lt;li&gt;强化学习智能体从环境中获得延迟的奖励&lt;/li&gt;
&lt;li&gt;强化学习过程中, 时间非常重要, 因为得到的是时间关联的数据.&lt;/li&gt;
&lt;li&gt;强化学习中, 智能体当前的动作会影响它随后的数据, 智能体需要保持稳定.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;强化学习并非凭空出世的奇想, 它是有一定的历史. 早期的强化学习, 一般被称为标准强化学习. 而最近业界把强化学习与深度学习结合起来, 就形成了&lt;strong&gt;深度强化学习&lt;/strong&gt;, 深度强化学习= 深度学习 + 强化学习.&lt;/p&gt;
&lt;h1&gt;二. 概念 -- 强化学习中基本术语&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;强化学习中有太多的概念了, 在不熟悉的情况下分散了解, 将非常打消阅读的热情. 在第二章中将常见的术语一并介绍, 方便回头查询, 也方便快速进入强化学习的理论情景中.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;探索 (exploration)&lt;/strong&gt; 指的是尝试一些新的动作, 这些动作的奖励不确定.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;利用 (exploitation)&lt;/strong&gt; 指的是采取已知的可以获取更多奖励的动作&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;预演 (rollout)&lt;/strong&gt; 指的是从当前帧度动作进行采样, 生成很多局游戏. 当然, 这个词在中文社区的翻译更多为“回合”或者“轨迹采样”.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;轨迹 (trajectory, $\tau$ )&lt;/strong&gt; : 当前智能体与环境交互, 会得到一系列&lt;strong&gt;观测 (observation)&lt;/strong&gt;, 每一个观测可以看成一个轨迹. 轨迹就是从当前帧以及它采取的&lt;strong&gt;策略&lt;/strong&gt;, 即状态和动作的序列:&lt;/p&gt;
&lt;p&gt;$$
\tau = (s_0,a_0,s_1,a_1...) \tag{2.1}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;最终奖励 (eventual reward)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一场游戏被称为一个&lt;strong&gt;回合 (episode)&lt;/strong&gt; 或者 试验 (trial)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;序列决策 (sequential decision making)&lt;/strong&gt; : 智能体把动作输出给环境, 环境取得这个动作之后会进行下一步, 把下一步的观测与这个动作带来的奖励返还给智能体. 智能体的目的是选取一系列动作来最大化奖励.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;学习(learning)&lt;/strong&gt; 和 &lt;strong&gt;规划(planning)&lt;/strong&gt; 是序列决策中的两个基本问题. 在学习中, 环境初始时是位置的, 它通过不断与环境交互, 逐步改进策略; 在规划中, 环境是已知的, 智能体能够计算出一个玩咩的模型, 并且在不需要与环境进行任何交互的时候进行计算, 寻找最优解.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;探索(exploration)&lt;/strong&gt; 和 &lt;strong&gt;利用(exploration)&lt;/strong&gt; 是强化学习中的两个核心问题. 因为尝试次数有限, 这两者实际上是矛盾的, 加强一方就会削弱另一方, 这就是强化学习中的&lt;strong&gt;探索-利用窘境 (exploration-exploitation dilemma)&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;奖励信号 (reward signal)&lt;/strong&gt; : 奖励是环境给的一种标量化的反馈信号. 智能体在环境里存在的目的就是最大化它的期望的&lt;strong&gt;累积奖励 (expected cummulative reward)&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;历史&lt;/strong&gt;是观测、动作、奖励的序列 (下标t一般表示当前步):&lt;/p&gt;
&lt;p&gt;$$
H_t=o_1,a_1,r_1,...,o_t,a_t,r_t \tag{2.2}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;状态&lt;/strong&gt;是对世界的完整描述, 不会隐藏世界的信息. &lt;strong&gt;观测&lt;/strong&gt;是对状态的部分描述, 可能会遗漏一些信息. 整个游戏的状态可以看作关于历史的函数:&lt;/p&gt;
&lt;p&gt;$$
s_t=f(H_t) \tag{2.3}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;完全可观测 (fully observed)&lt;/strong&gt;: 环境有自己的函数$s^c_t=f^c(H_t)$ 来更新状态, 智能体内部有$s^a_t=f^a(H_t)$ 来更新状态. 当智能体状态与环境状态等价的时候, 即当智能体能够观察到环境的所有状态时, 我们称这个环境是完全可观测的.&lt;/p&gt;
&lt;p&gt;$$
o_t=s^c_t=s^a_t \tag{2.4}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当完全可观测时, 强化学习通常被建模为&lt;strong&gt;马尔可夫决策过程(MDP)&lt;/strong&gt;; 部分可观测下则会被建模为&lt;strong&gt;部分可观测马尔可夫决策过程(POMDP)&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;动作空间 (action space)&lt;/strong&gt; 指的是给定环境中有效动作的集合. 如果智能体的动作数量有限就叫做&lt;strong&gt;离散动作空间 (discrete action space)&lt;/strong&gt; , 如果智能体的动作是实值的向量, 则是&lt;strong&gt;连续动作空间 (continuous action space)&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;策略 (policy)&lt;/strong&gt;: 智能体会用策略来选取下一步的动作. 策略可以分为随机性策略 (stochastic policy)和确定性策略 (deterministic policy).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;价值函数 (value function)&lt;/strong&gt;: 价值函数用于评估智能体进入某个状态后, 可以对后面的奖励带来多大的影响. 价值函数值越大, 说明智能体进入这个状态越有利. 加入折扣因子 (discount factor), 价值函数可以被定义为:&lt;/p&gt;
&lt;p&gt;$$
V_\pi(s)\doteq\mathbb{E}&lt;em&gt;\pi\left[G_t\mid s_t=s\right]=\mathbb{E}&lt;/em&gt;\pi\left[\sum_{k=0}^\infty\gamma^kr_{t+k+1}\mid s_t=s\right],\quad \forall s\in S \tag{2.5}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;式2.5中, $\mathbb{E}_\pi$ 的下标为$\pi$ 函数, 它的值可以反映我们在使用策略$\pi$ 的时候, 到底可以获得多少奖励.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Q函数&lt;/strong&gt;: 也是一种价值函数, 其中包含两个变量: 状态和动作. 其定义为:
$$
Q_\pi(s,a) \doteq \mathbb{E}&lt;em&gt;\pi\left[G_t \mid s_t=s, a_t=a\right] = \mathbb{E}&lt;/em&gt;\pi\left[\sum_{k=0}^\infty \gamma^k r_{t+k+1} \mid s_t=s, a_t=a\right]\tag{2.6}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;模型 (model)&lt;/strong&gt;: 模型表示智能体对环境状态进行理解, 它决定了环境中世界的运行方式. 模型决定了下一步的状态, 下一步的状态取决于当前的状态以及当前采取的动作. 它由状态转移概率和奖励函数两个部分组成. &lt;strong&gt;状态转移概率&lt;/strong&gt;即:&lt;/p&gt;
&lt;p&gt;$$
p_{ss^{\prime}}^a=p\left(s_{t+1}=s^{\prime}\mid s_t=s,a_t=a\right)\tag{2.7}
$$&lt;/p&gt;
&lt;p&gt;即某s中采取某a并非一定可以得到特定的下一个s, 而是概率的.
&lt;strong&gt;奖励函数&lt;/strong&gt;是指我们在当前状态采取了某个动作, 可以获得多大奖励:&lt;/p&gt;
&lt;p&gt;$$
R(s,a)=\mathbb{E}\left[r_{t+1}\mid s_t=s,a_t=a\right] \tag{2.8}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;马尔可夫决策过程(Markov decision process)&lt;/strong&gt; 由策略、价值函数和模型三个部分组成. 如下图, 这个决策过程可视化了状态的转移和采取的动作:
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026131445.CTFQ1CG6.png&amp;amp;w=956&amp;amp;h=654&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;智能体可以分为&lt;strong&gt;基于价值的智能体 (value-based agent)&lt;/strong&gt; 和&lt;strong&gt;基于策略的智能体 (policy-based agent)&lt;/strong&gt;. 前者显式学习价值函数, 隐式学习策略; 后者直接学习策略, 我们给出一个状态, 它就会输出对应动作的概率.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;基于价值和基于策略的智能体结合可以得到&lt;strong&gt;演员-批评家智能体 (actor-critic agent)&lt;/strong&gt;, 这一类智能体吧策略和价值函数都学习了, 通过两者的交互得到最佳的动作.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;智能体还可以分为&lt;strong&gt;有模型(model-based)&lt;/strong&gt; 和 &lt;strong&gt;免模型(model-free)&lt;/strong&gt;, 前者通过学习状态的转移来采取动作(如DP, 蒙特卡洛), 后者没有直接估计状态的转移, 也没有得到环境的具体转移变量, 它通过学习价值函数和策略函数进行决策(如Q-learning, DQN和Policy Gradient).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;有模型强化学习比免模型强化学习多出一个步骤, 就是对真实世界建模. 免模型强化学习通常属于数据驱动方法, 需要大量的采样来估计状态、动作及奖励函数, 从而优化动作策略.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;范围 (Horizon)&lt;/strong&gt;: 一个回合的长度(每个回合最大的时间步数), 它是由有限个步骤决定的.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;回报 (return)&lt;/strong&gt;: 可以定义为奖励的逐步累加, 假设时刻$t$ 后的奖励序列为$r_{t+1}, r_{t+2}, r_{t+3}, \cdots$ , 折扣因子为$\gamma$ , 越往后得到的奖励折扣越多. 则回报为:&lt;/p&gt;
&lt;p&gt;$$
G_t = r_{t+1} + \gamma r_{t+2} + \gamma^2 r_{t+3} + \gamma^3 r_{t+4} + \ldots + \gamma^{T-t-1} r_T \tag{2.9}
$$&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;折扣因子(discount factor)&lt;/strong&gt;: 我们使用折扣因子, 一方面过程转移可能是带环的, 我们要避免无限循环; 另一方面, 我们并不能建立完美的模拟环境的模型, 我们对未来的评估不一定是准确的; 还有就是, 如果奖励是有实际价值的, 我们更希望立刻就获得奖励, 而不后面再得到奖励.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;三. 马尔可夫决策过程&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;对基本术语进行一定程度的梳理之后, 我们就可以进入强化学习的语境当中. 但是在学习具体的算法之前, 我们还是要进行一定程度的理论扩容, 特别需要理解算法产生的背景. 紧接着我们就会介绍马尔可夫决策过程控制的两种算法, 策略迭代和价值迭代.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 马尔可夫性质 (Markov property)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;马尔可夫性质 (Markov property)&lt;/strong&gt; 是指一个随机过程在给定现在状态及所有过去状态情况下, 其未来状态的概率分布仅依赖于当前状态. 以离散随机过程为例, 假设随机变量$X_{0},X_{1},\cdots,X_{T}$ 构成一个随机过程, 这些随机变量的所有可能取值的集合被称为状态空间 (state space), 如果过去状态的条件概率分布为仅是$X_t$ 的一个函数, 则:&lt;/p&gt;
&lt;p&gt;$$
p\left(X_{t+1}=x_{t+1}\mid X_{0:t}=x_{0:t}\right)=p\left(X_{t+1}=x_{t+1}\mid X_{t}=x_{t}\right) \tag{3.1}
$$
其中, $X_{0:t}$ 表示变量集合$X_{0},X_{1},\cdots,X_{T}$ , $x_{0:t}$ 表示状态空间中的状态序列$x_0,x_1,\cdots,x_t$ .&lt;/p&gt;
&lt;p&gt;马尔可夫性质也可以描述为, 将来的状态和过去的状态是条件独立的.&lt;/p&gt;
&lt;h2&gt;2. 马尔可夫链(Markov chain)&lt;/h2&gt;
&lt;p&gt;马尔可夫过程是一组具有马尔可夫性质的随机变量序列$s_0,s_1,\cdots,s_t$ 其中下一个时刻的状态$s_{t+1}$ 只取决于当前状态$s_{t}$ .&lt;/p&gt;
&lt;p&gt;我们设状态的历史为$h_t={s_1,s_2,s_3,\cdots,s_t}$  ($h_t$ 包含了之前的所有状态), 则马尔可夫过程满足条件:&lt;/p&gt;
&lt;p&gt;$$
p(s_{t+1} | s_t) = p(s_{t+1} | h_t) \tag{3.2}
$$
也就是说, 从当前$s_t$ 转移到$s_{t+1}$  ,它是直接就等于它之前所有的状态转移到$s_{t+1}$ . 离散时间的马尔可夫过程也被称为&lt;strong&gt;马尔可夫链(Markov chain)&lt;/strong&gt;.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026144630.CE4r1Oxs.png&amp;amp;w=666&amp;amp;h=588&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
我们可以用状态转移矩阵(state transition matrix) $P$ 来描述状态转移 $p(s_{t+1}= s&apos;|s_t=s)$:
$$
\boldsymbol{P}=\left(\begin{array}{cccc}p\left(s_{1}\mid s_{1}\right)&amp;amp;p\left(s_{2}\mid s_{1}\right)&amp;amp;\ldots&amp;amp;p\left(s_{N}\mid s_{1}\right)\p\left(s_{1}\mid s_{2}\right)&amp;amp;p\left(s_{2}\mid s_{2}\right)&amp;amp;\ldots&amp;amp;p\left(s_{N}\mid s_{2}\right)\\vdots&amp;amp;\vdots&amp;amp;\ddots&amp;amp;\vdots\p\left(s_{1}\mid s_{N}\right)&amp;amp;p\left(s_{2}\mid s_{N}\right)&amp;amp;\ldots&amp;amp;p\left(s_{N}\mid s_{N}\right)\end{array}\right) \tag{3.3}
$$&lt;/p&gt;
&lt;h2&gt;3. 马尔可夫奖励过程 (Markov reward process, MRP)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;马尔可夫奖励过程 (Markov reward process, MRP)&lt;/strong&gt; 是马尔可夫链加上奖励函数. 在马尔可夫奖励过程中, 状态转移矩阵和状态都与马尔可夫链一样, 只是多了奖励函数.&lt;/p&gt;
&lt;p&gt;前面已经介绍过回报$G_t$ , 我们可以定义状态的价值, 就是&lt;strong&gt;状态价值函数 (state-value function)&lt;/strong&gt; :&lt;/p&gt;
&lt;p&gt;$$
\begin{aligned} V^{t}(s) &amp;amp;= \mathbb{E}\left[G_{t} \mid s_{t} = s\right] \ &amp;amp;= \mathbb{E}\left[r_{t+1} + \gamma r_{t+2} + \gamma^{2} r_{t+3} + \ldots + \gamma^{T-t-1} r_{T} \mid s_{t} = s\right] \end{aligned} \tag{3.4}
$$
这个期望就是从这个状态开始, 我们可能获得多大的价值. 也可以说是, 未来可能获得的价值在当前价值的表现, 就是当我们进入某一个状态后, 我们现在能有多大的价值.&lt;/p&gt;
&lt;h2&gt;4. 贝尔曼方程&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;前面已经得出来了状态价值函数, 这里就引出了一个问题: 当我们有了一些轨迹的实际回报时, 怎么计算它的价值函数. 一个可行的方法就是从当前状态生成许多轨迹, 然后把轨迹都叠加起来 (比如取平均值, 这就是一种计算价值函数的方法, 被称为&lt;strong&gt;蒙特卡洛(MonteCarlo, MC)&lt;/strong&gt; 采样). 但是我们这里学习另一种更多的方法, 就是从价值函数里推导出贝尔曼方程.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;贝尔曼方程 (Bellman equation)&lt;/strong&gt; 就是当前状态与未来状态的迭代关系, 表示当前状态的价值函数可以通过下个状态的价值函数来计算.&lt;/p&gt;
&lt;p&gt;我们现在来推导这个公式, 首先我们需要得出推导所需要的一个前置公式4.3.&lt;/p&gt;
&lt;p&gt;为了简洁, 我们把当前步的t下标去掉, 而把t+1步下标改成t‘, 按照期望的定义, 我们重写回报的期望:&lt;/p&gt;
&lt;p&gt;$$
\begin{aligned} \mathbb{E}\left[G_{t+1} \mid s_{t+1}\right] &amp;amp;= \mathbb{E}\left[g^{\prime} \mid s^{\prime}\right] \ &amp;amp;= \sum_{g^{\prime}} g^{\prime} , p\left(g^{\prime} \mid s^{\prime}\right) \end{aligned} \tag{4.1}
$$&lt;/p&gt;
&lt;p&gt;我们再次对式4.1求期望:
$$
\begin{aligned}\mathbb{E}\left[\mathbb{E}\left[G_{t+1}\mid s_{t+1}\right]\mid s_{t}\right]&amp;amp;=\mathbb{E}\left[\mathbb{E}\left[g^{\prime}\mid s^{\prime}\mid s\right]\mid s\right]\&amp;amp;=\mathbb{E}\left[\sum_{g^{\prime}}g^{\prime}\left.p\left(g^{\prime}\mid s^{\prime}\right)\mid s\right]\right]\&amp;amp;=\sum_{{s^{\prime}}}\sum_{{g^{\prime}}}g^{\prime}p\left(g^{\prime}\mid s^{\prime},s\right)p\left(s^{\prime}\mid s\right)\&amp;amp;=\sum_{{s^{\prime}}}\sum_{{g^{\prime}}}\frac{g^{\prime}p\left(g^{\prime}\mid s^{\prime},s\right)p\left(s^{\prime}\mid s\right)p(s)}{p(s)}\&amp;amp;=\sum_{{s^{\prime}}}\sum_{{g^{\prime}}}\frac{g^{\prime}p\left(g^{\prime}\mid s^{\prime},s\right)p\left(s^{\prime},s\right)}{p(s)}\&amp;amp;=\sum_{{s^{\prime}}}\sum_{{g^{\prime}}}\frac{g^{\prime}p\left(g^{\prime},s^{\prime},s\right)}{p(s)}\&amp;amp;=\sum_{{s^{\prime}}}\sum_{{g^{\prime}}}g^{\prime}p\left(g^{\prime},s^{\prime}\mid s\right)\&amp;amp;=\sum_{{g^{\prime}}}\sum_{{g^{\prime}}}g^{\prime}p\left(g^{\prime},s^{\prime}\mid s\right)\&amp;amp;=\sum_{{g^{\prime}}}g^{\prime}p\left(g^{\prime}\mid s\right)\&amp;amp;=\mathbb{E}\left[g^{\prime}\mid s\right]=\mathbb{E}\left[G_{t+1}|s_t\right] \end{aligned} \tag{4.2}
$$
$$
E[G_{t+1}|s_t] = E[ E[G_{t+1}|s_t, s_{t+1}] | s_t] = E[ E[G_{t+1}|s_{t+1}] | s_t]
$$&lt;/p&gt;
&lt;p&gt;而实际上, 结合状态价值函数的定义3.4, 我们可以得到4.2的期望就是对价值函数的期望, 然后结合4.2 就得到了:
$$
\mathbb{E}[V(s_{t+1})|s_t]=\mathbb{E}[\mathbb{E}[G_{t+1}|s_{t+1}]|s_t]=\mathbb{E}\left[G_{t+1} \mid s_{t}\right] \tag{4.3}
$$
这个4.3就是推导贝尔曼公式重要的前提. 现在我们就开始推导贝尔曼公式:
$$
\begin{aligned}V(s)&amp;amp;=\mathbb{E}\left[G_t\mid s_t=s\right]\&amp;amp;=\mathbb{E}\left[r_{t+1}+\gamma r_{t+2}+\gamma^2r_{t+3}+\ldots\mid s_t=s\right]\&amp;amp;=\mathbb{E}\left[r_{t+1}|s_t=s\right]+\gamma\mathbb{E}\left[r_{t+2}+\gamma r_{t+3}+\gamma^2r_{t+4}+\ldots\mid s_t=s\right]\&amp;amp;=R(s)+\gamma\mathbb{E}[G_{t+1}|s_t=s]\&amp;amp;=R(s)+\gamma\mathbb{E}[V(s_{t+1})|s_t=s]\&amp;amp;=R(s)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s\right)V\left(s^{\prime}\right)\end{aligned} \tag{4.4}
$$
4.4就是我们需要的状态价值函数的迭代形式, 贝尔曼公式. 它说明了当前状态的价值, 是由当前的回报和未来状态的价值的总和.&lt;/p&gt;
&lt;p&gt;如果要得到所有的状态价值, 我们可以把贝尔曼方程写成矩阵的形式:
$$
\left.\left(\begin{array}{c}V\left(s_{1}\right)\V\left(s_{2}\right)\\vdots\V\left(s_{N}\right)\end{array}\right.\right)=\left(\begin{array}{c}R\left(s_{1}\right)\R\left(s_{2}\right)\\vdots\R\left(s_{N}\right)\end{array}\right)+\gamma\left(\begin{array}{cccc}p\left(s_{1}\mid s_{1}\right)&amp;amp;p\left(s_{2}\mid s_{1}\right)&amp;amp;\ldots&amp;amp;p\left(s_{N}\mid s_{1}\right)\p\left(s_{1}\mid s_{2}\right)&amp;amp;p\left(s_{2}\mid s_{2}\right)&amp;amp;\ldots&amp;amp;p\left(s_{N}\mid s_{2}\right)\\vdots&amp;amp;\vdots&amp;amp;\ddots&amp;amp;\vdots\p\left(s_{1}\mid s_{N}\right)&amp;amp;p\left(s_{2}\mid s_{N}\right)&amp;amp;\ldots&amp;amp;p\left(s_{N}\mid s_{N}\right)\end{array}\right)\left(\begin{array}{c}V\left(s_{1}\right)\V\left(s_{2}\right)\\vdots\V\left(s_{N}\right)\end{array}\right) \tag{4.5}
$$
而写成矩阵形式后, 实际上我们可以用求矩阵逆的方法来求解析解:
$$
\begin{aligned} \boldsymbol{V}&amp;amp;=\boldsymbol{R}+\gamma\boldsymbol{P}\boldsymbol{V}\
\boldsymbol{I}\boldsymbol{V}&amp;amp;=\boldsymbol{R}+\gamma\boldsymbol{P}\boldsymbol{V}\
(\boldsymbol{I}-\gamma\boldsymbol{P})\boldsymbol{V}&amp;amp;=\boldsymbol{R}\
\boldsymbol{V}&amp;amp;=(\boldsymbol{I}-\gamma\boldsymbol{P})^{-1}\boldsymbol{R}\end{aligned} \tag{4.6}
$$
但是矩阵求逆的过程的复杂度都是$O(N^3)$ , 计算量非常大, 所以这只适用于很小量的马尔可夫奖励过程.&lt;/p&gt;
&lt;h2&gt;5. 求解价值的方法&lt;/h2&gt;
&lt;p&gt;对于强化学习而言, 最终的目标是求出最优策略, 所有&lt;strong&gt;策略评估&lt;/strong&gt; 是非常重要的.&lt;/p&gt;
&lt;p&gt;已知马尔可夫决策过程以及要采取的策略$\pi$ , &lt;strong&gt;计算价值函数$V_\pi(s)$&lt;/strong&gt; 的过程就是&lt;strong&gt;策略评估&lt;/strong&gt;, 策略评估在有些地方也被称为 &lt;strong&gt;(价值)预测[(value) prediction]&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;由于这个评估需要贯穿强化学习的始终, 在这里展开介绍所有方法是不合适的, 因此读者可以多留意之后一些算法当中, 都会有的策略评估的过程和方法.&lt;/p&gt;
&lt;h2&gt;6. &lt;strong&gt;马尔可夫决策过程 (Markov decision process, MDP)&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026162753.eogspSf7.png&amp;amp;w=1070&amp;amp;h=410&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
相对于马尔可夫奖励过程, 马尔可夫决策过程多了决策 (决策是指动作), 其他的定义与马尔可夫奖励过程是类似. 此外状态转移概率也多了一个条件, 变成了$p(s_{t+1}=s&apos;|s_t=s,a_t=a)$. 它的意思是, 未来的状态不仅依赖于现在的状态, 也依赖于在当前状态智能体采取的动作. 马尔可夫决策过程满足:
$$
p(s_{t+1} | s_t, a_t) = p(s_{t+1} | h_t, a_t) \tag{6.1}
$$
对于奖励函数, 也多了一个当前的动作, 变成了&lt;/p&gt;
&lt;p&gt;$$
R(s_{t}=s,a_{t}=a)=\mathbb{E}[r_{t}\mid s_{t}=s,a_{t}=a] \tag{6.2}
$$
由于开始涉及到智能体的动作了, 所以就要有一定的策略. 前面我们已经知道, 策略定义了在某一个状态应该采用什么动作, 所以知道当前状态之后, 我们带入策略函数就能得到一个策略:
$$
\pi(a \mid s) = p(a_t = a \mid s_t = s) \tag{6.3}
$$
这里的概率, 就代表了在所有可能的动作里面怎样采取行动. (比如0.5概率往左, 0.5概率往右).&lt;/p&gt;
&lt;p&gt;已知马尔可夫决策过程 (别忘了, 马尔可夫决策过程实际上就是策略+价值函数+模型)和策略函数$\pi$ , 我们就可以将马尔可夫决策过程转化成马尔可夫奖励过程. 因为我们已知策略函数, 也就是已知每种状态下, 可能采取的动作的概率, 所以我们就可以直接把动作进行加和, 去掉动作$a$, 用策略(概率)来代替 :
$$
P_\pi\left(s^{\prime}\mid s\right)=\sum_{a\in A}\pi(a\mid s)p\left(s^{\prime}\mid s,a\right) \tag{6.4}
$$
对于奖励函数, 同样把动作去掉, 得到类似马尔可夫奖励过程的奖励函数:
$$
r_\pi(s)=\sum_{a\in A}\pi(a\mid s)R(s,a) \tag{6.5}
$$
马尔可夫决策过程的价值函数定义的与式3.4一样. 但是由于我们这里多出了动作a, 不好处理. 所以我们这里引入了一个&lt;strong&gt;Q函数 (Q-function)&lt;/strong&gt;, Q函数也被称为&lt;strong&gt;动作价值函数 (actino-value)&lt;/strong&gt;. Q函数的定义是在某一个状态采取某一个动作, 它有可能得到的回报的一个期望, 即:
$$
Q_\pi(s,a)=\mathbb{E}&lt;em&gt;\pi\left[G_t\mid s_t=s,a_t=a\right] \tag{6.6}
$$
这里的期望也是基于策略函数的, 所以我们要对策略函数进行一个加和, 然后得到它的价值. 对于Q函数中的动作进行加和, 就可以得到价值函数:
$$
V&lt;/em&gt;\pi(s)=\sum_{a\in A}\pi(a\mid s)Q_\pi(s,a) \tag{6.7}
$$
紧接着, 我们对&lt;strong&gt;Q函数的贝尔曼方程&lt;/strong&gt;进行推导, 类似于上述4.4的推导:
$$
\begin{aligned}Q(s,a)&amp;amp;=\mathbb{E}\left[G_t\mid s_t=s,a_t=a\right]\&amp;amp;=\mathbb{E}\left[r_{t+1}+\gamma r_{t+2}+\gamma^2r_{t+3}+\ldots\mid s_t=s,a_t=a\right]\&amp;amp;=\mathbb{E}\left[r_{t+1}|s_t=s,a_t=a\right]+\gamma\mathbb{E}\left[r_{t+2}+\gamma r_{t+3}+\gamma^2r_{t+4}+\ldots\mid s_t=s,a_t=a\right]\&amp;amp;=R(s,a)+\gamma\mathbb{E}[G_{t+1}|s_t=s,a_t=a]\&amp;amp;=R(s,a)+\gamma\mathbb{E}[V(s_{t+1})|s_t=s,a_t=a]\&amp;amp;=R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V\left(s^{\prime}\right)\end{aligned} \tag{6.8}
$$
我们可以看到上述6.9和6.10代表了状态价值函数与Q函数之间的关联, 因为Q中有V, V中有Q.&lt;/p&gt;
&lt;p&gt;观察形式, 状态价值函数和Q函数都可以拆分成两个部分: 即时奖励和后续状态的折扣价值. 通过对状态价值函数进行分解, 我们可以得到类似于之前马尔可夫奖励过程的贝尔曼方程 -- &lt;strong&gt;贝尔曼期望方程 (Bellman expectation equation)&lt;/strong&gt;.
$$
V_\pi(s)=\mathbb{E}&lt;em&gt;\pi\left[r&lt;/em&gt;{t+1}+\gamma V_\pi\left(s_{t+1}\right)\mid s_t=s\right] \tag{6.9}
$$
类似的, 对于Q函数分解, 得到贝尔曼期望方程:
$$
Q_{\pi}(s,a) = \mathbb{E}&lt;em&gt;{\pi}\left[r&lt;/em&gt;{t+1} + \gamma Q_{\pi}(s_{t+1},a_{t+1}) , \middle| , s_t = s, a_t = a\right], a_{t+1} \sim π(·|s_{t+1})\tag{6.10}
$$
我们继续往下推导, 首先把6.8在策略$\pi$ 的时候代入6.7, 可以得到:
$$
V_\pi(s)=\sum_{a\in A}\pi(a\mid s)\left(R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V_\pi\left(s^{\prime}\right)\right) \tag{6.11}
$$&lt;/p&gt;
&lt;p&gt;6.11表示了当前状态价值与未来状态价值的关联. 然后我们再反过来代入, 将6.7代入6.8中, 可以得到:
$$
Q_\pi(s,a)=R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)\sum_{a^{\prime}\in A}\pi\left(a^{\prime}\mid s^{\prime}\right)Q_\pi\left(s^{\prime},a^{\prime}\right) \tag{6.12}
$$
这个式子代表了当前时刻的Q函数与未来时刻的Q函数之间的关联. 式6.11和6.12是贝尔曼期望方程的另一种形式.&lt;/p&gt;
&lt;h2&gt;7. 从建模走向控制问题&lt;/h2&gt;
&lt;p&gt;前面更多是在回答“给定环境和策略，价值到底怎么定义、怎么计算”；但强化学习真正关心的问题是：&lt;strong&gt;怎样找到更优的策略&lt;/strong&gt;。因此，接下来视角会从建模与评估，转到控制与最优决策。&lt;/p&gt;
&lt;h2&gt;8. 马尔可夫决策过程控制&lt;/h2&gt;
&lt;p&gt;但是如果只有马尔可夫决策过程, 那么如何来寻找最佳的策略?&lt;/p&gt;
&lt;p&gt;我们引入了&lt;strong&gt;最佳价值函数(optimal value function)&lt;/strong&gt;, 它是指我们搜寻一种策略$\pi$ 让每个状态的价值最大:
$$
V^&lt;em&gt;(s)=\max_\pi V_\pi(s) \tag{8.1}
$$
在这种最大化情况下, 得到的策略就是最佳策略:
$$
\pi^&lt;/em&gt;(s)=\arg\max_\pi V_\pi(s) \tag{8.2}
$$
换句话说, 最佳策略让每个状态的价值函数都取到最大值. 所以如果我们可以得到一个最佳价值函数, 就可以认为某个马尔可夫决策过程的环境可解.&lt;/p&gt;
&lt;p&gt;而在可解的情况下, 最佳价值函数是一致的, 环境中可达到的上限的值是一致的, 但这里可能有多个最佳策略.&lt;/p&gt;
&lt;p&gt;当取得最佳价值函数后, 我们可以通过Q函数进行最大化来得到最佳策略:
$$
\pi^&lt;em&gt;(a\mid s)=\left{\begin{array}{ll}1,&amp;amp;a=\underset{a\in A}{\operatorname&lt;/em&gt;{\arg\max}}Q^&lt;em&gt;(s,a)\0,&amp;amp;\text{其他}\end{array}\right. \tag{8.3}
$$
当Q函数收敛之后, 因为Q函数是关于状态与动作的函数, 所以如果在某个状态采取某个动作, 可以使Q函数最大化, 那么这个动作就是最佳的动作. 如果我们能优化出一个Q函数$Q^&lt;/em&gt;(s,a)$, 就可以直接在Q函数中取一个让Q函数值最大化的动作的值, 就可以提取出最佳策略.&lt;/p&gt;
&lt;p&gt;吗? 那怎么提取呢. 其实最容易想到的方法, 就是穷举所有方法, 如果动作和状态有限, 就可以对每个状态采取A中动作的策略, 总共$|A|^{|S|}$ 个可能策略. 然算出每种策略下的价值函数问题就解决. 但是显然没有效率, 所以目前有两种常用的方法来搜索最佳策略.&lt;/p&gt;
&lt;h3&gt;(1) 策略迭代与贝尔曼最优方程&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;策略迭代不仅仅是求解上述model-based  (即已知状态转移函数和奖励函数, 或者说已知价值函数) 的一种方法, 也是后面MDP继续优化的基础, 要好好学习其思想.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;策略迭代 (Policy Iteration)&lt;/strong&gt; 由两个步骤组成 -- 策略评估和和策略改进 (policy improvement). 在我们优化策略$\pi$ 时, 在优化过程中得到一个最新的策略. 我们先保持这个策略不变, 然后估计它的价值, 即给定当前的策略函数来估计状态价值函数的值. 然后, 得到状态价值函数后, 可以进一步计算它的Q函数. 得到Q函数后, 我们直接对Q函数进行最大化, 通过在Q函数做一个贪心的搜索来进一步改进策略, 这两个策略迭代进行.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026201406.DAjIMWd-.png&amp;amp;w=1224&amp;amp;h=572&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;接下来, 我们来具体看一下是怎么进行greedy进行策略改进的. 首先, 我们再复习一下, 已知第i个策略对应的状态价值函数, 我们可以根据6.8式得到Q, 如下:
$$
Q_{\pi_i}(s,a)=R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V_{\pi_i}\left(s^{\prime}\right) \tag{8.4}
$$
对于每个状态, 策略改进会得到它的新一轮的策略, 对于每个状态, 我们取得它得到最大值的动作, 即:
$$
\pi_{i+1}(s)=\underset{a}{\operatorname*{\arg\max}}Q_{\pi_i}(s,a) \tag{8.5}
$$
这是一个确定性策略, 新的策略在每个状态s都&lt;strong&gt;确定性地&lt;/strong&gt;选择能使 $Q_{π_i}(s, a)$ 最大的动作 a.&lt;/p&gt;
&lt;p&gt;我们其实可以把Q函数看成一个&lt;strong&gt;Q表格 (Q-table)&lt;/strong&gt;, 横轴是它的状态, 纵轴是它可能的动作. 如果我们得到了Q函数, Q表格也就得到了. 所以上述argmax操作就是在选择每一列 (状态) 中最大的行 (动作).
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026202321.L_TLtOlq.png&amp;amp;w=652&amp;amp;h=320&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当我们一直采取argmax操作的时候, 就会得到一个单调的递增. 我们通过采取这种贪心操作, 就会得到更好或者保持不变的策略, 而不是使价值函数变差. 所以当停止改进之后, 我们取让Q函数最大的动作, Q函数就会直接变成价值函数:
$$
Q_\pi\left(s,\pi^{\prime}(s)\right)=\max_{a\in A}Q_\pi(s,a)=Q_\pi(s,\pi(s))=V_\pi(s) \tag{8.6}
$$
上述等式的意思是, 采用贪心a直到$\pi&apos;$ 不会再比$\pi$ 的Q值大了, 这时这个Q就是V.&lt;/p&gt;
&lt;p&gt;我们对等式最后一步做一点解释, 一般情况下, 马尔可夫决策过程中Q与V是满足6.7的关系, 即V是Q在$\pi$ 下的加权求和. 但是在最优策略下, 如8.3所示, 它是一个确定性策略, 最优动作的贡献为1, 其他动作贡献全部为0:&lt;/p&gt;
&lt;p&gt;$$
V_{\pi^&lt;em&gt;}(s) = \sum_{a} \pi^&lt;/em&gt;(a|s)Q_{\pi^&lt;em&gt;}(s,a) = 1 \cdot Q_{\pi^&lt;/em&gt;}(s,a^&lt;em&gt;) + 0 \cdot \text{其他} = Q_{\pi^&lt;/em&gt;}(s,a^*) \tag{8.7}
$$&lt;/p&gt;
&lt;p&gt;而其中的$a^&lt;em&gt;$ 正是让Q最大的函数, 即$Q_{\pi^&lt;/em&gt;}(s, a^&lt;em&gt;) = \max_{a} Q_{\pi^&lt;/em&gt;}(s, a)$, 所以得到了8.6的最后一个等号.&lt;/p&gt;
&lt;p&gt;上述其实就是&lt;strong&gt;贝尔曼最优方程 (Bellman optimality equation)&lt;/strong&gt;, 贝尔曼最优方程表明最佳策略下的一个状态的价值必须等于在这个状态下采取最好动作得到的回报的期望. 换句话说, &lt;strong&gt;最优状态价值就是最优动作价值的&quot;最大价值&quot;&lt;/strong&gt;:
$$
V^&lt;em&gt;(s)=\max_aQ^&lt;/em&gt;(s,a) \tag{8.8}
$$
然后结合Q值的贝尔曼方程6.8, 代入8.8可以得到:
$$
\begin{aligned}Q^{&lt;em&gt;}(s,a)&amp;amp;=R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V^{&lt;/em&gt;}\left(s^{\prime}\right)\&amp;amp;=R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)\max_{a}Q^{*}(s^{\prime},a^{\prime})\end{aligned} \tag{8.9}
$$
于是我们的得到了Q的转移过程. 通过上述的讨论, 应该明白迭代式子得出的意义, 所以当然, Q学习就是基于这个8.9式进行的, 但是由于属于非常经典的算法, 所以我们放在后面单独介绍.&lt;/p&gt;
&lt;h3&gt;(2) 价值迭代&lt;/h3&gt;
&lt;p&gt;现在我们换个角度思考问题, 动态规划的方法将优化问题分成两个部分. 第一步执行的是最优的动作, 后继的状态每一步都按照最优策略去做, 最后的结果就是最优的.&lt;/p&gt;
&lt;p&gt;这里我们可以引入&lt;strong&gt;最优性原理定理 (principle of optimality theorem)&lt;/strong&gt;: 一个策略$\pi(a|s)$ 在状态s达到了最优价值, 也就是$V_{\pi}(s)=V^&lt;em&gt;(s)$ 成立, 当且仅当对于任何能从$s$ 到达的$s&apos;$ , 都已经达到了最优价值, 也就是对于所有的$s&apos;$ , $V_{\pi}(s&apos;)=V^&lt;/em&gt;(s&apos;)$ 恒成立.&lt;/p&gt;
&lt;p&gt;这就告诉我们, 如果知道了子问题$V^*(s&apos;)$ 的最优解, 就可以通过&lt;strong&gt;价值迭代&lt;/strong&gt;来得到最优的$V_{\pi}(s)$ 的解.&lt;/p&gt;
&lt;p&gt;我们可以继续把8.9代入到8.8中:
$$
\begin{aligned}V^&lt;em&gt;(s)&amp;amp;=\max_aQ^&lt;/em&gt;(s,a)\&amp;amp;=\max_a\mathbb{E}[G_t|s_t=s,a_t=a]\&amp;amp;=\max_a\mathbb{E}[r_{t+1}+\gamma G_{t+1}|s_t=s,a_t=a]\&amp;amp;=\max_a\mathbb{E}[r_{t+1}+\gamma V^&lt;em&gt;(s_{t+1})|s_t=s,a_t=a]\&amp;amp;=\max_a\mathbb{E}[r_{t+1}]+\max_a\mathbb{E}[\gamma V^&lt;/em&gt;(s_{t+1})|s_t=s,a_t=a]\&amp;amp;=\max_aR(s,a)+\max_a\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V^&lt;em&gt;\left(s^{\prime}\right)\&amp;amp;=\max_a\left(R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V^&lt;/em&gt;\left(s^{\prime}\right)\right)\end{aligned} \tag{8.10}
$$
这样, 我们就也得到了状态价值函数的转移. 我们把贝尔曼最优方程当作一个更新规则来进行, 即:
$$
V(s)\leftarrow\max_{a\in A}\left(R(s,a)+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V\left(s^{\prime}\right)\right) \tag{8.11}
$$
当整个马尔可夫决策过程以及达到最佳的状态时, 式8.11才满足. 但是我们可以转化为一个迭代的等式, 不断迭代贝尔曼最优方程, 价值函数就能逐渐趋向于最佳的价值函数, 这就是价值迭代算法的精髓. 价值迭代算法可以用下面过程总结:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初始化：令 k=1，对于所有状态 s，$V_{0}(s)$=0。&lt;/li&gt;
&lt;li&gt;对于 k=1: H（H 是让 V(s) 收敛所需的迭代次数）
&lt;ul&gt;
&lt;li&gt;对于所有状态 s
$Q_{k+1}(s,a)=R(s,a)+\gamma\sum_{s&apos;\in S}p(s&apos;|s,a)V_{k}(s&apos;)$
$V_{k+1}(s)=\max_{a}Q_{k+1}(s,a)$&lt;/li&gt;
&lt;li&gt;k←k+1。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;在迭代后提取最优策略：
$\pi(s)=\arg\max_{a}\left[R(s,a)+\gamma\sum_{s&apos;\in S}p(s&apos;|s,a)V_{H+1}(s&apos;)\right]$&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;四. 总结&lt;/h1&gt;
&lt;p&gt;至此, 我们奠定了强化学习解决序列决策问题的理论基础. 然而, 上述方法（策略迭代、价值迭代）通常要求我们知道环境的动态模型（即状态转移概率 &lt;code&gt;P&lt;/code&gt; 和奖励函数 &lt;code&gt;R&lt;/code&gt;）. 在现实中, 例如训练一个LLM Agent, 我们往往无法获得这个模型. 这就引出了下一章的核心——&lt;strong&gt;免模型强化学习&lt;/strong&gt;, 包括著名的Q-learning、Policy Gradient等算法, 它们将是我们将LLM与RL结合的关键工具.&lt;/p&gt;
&lt;p&gt;如果你是从头看到现在的, 劝你还是停一下, 冷静思考回顾一遍. 因为即使是写到这里, 也是花了我一个礼拜的时间, 但依然有被大量形式公式带着走的感觉.&lt;/p&gt;
&lt;p&gt;回顾一下我们的历程. 我们要将强化学习建模为马尔可夫决策过程, 但是必须从基层开始. 开始, 我们只关注状态之间概率的转移, 叫做马尔可夫过程. 然后我们给每个状态加上奖励值, 这就构成了马尔可夫奖励过程(MRP), 紧接着, 我们根据定义得出来每个状态的价值函数, 并通过贝尔曼方程将其写成了递归形式.&lt;/p&gt;
&lt;p&gt;然后我们在这里就开始讨论了求解这个方程的三种方法 (外加一个解析解), 能够求出所有状态的价值.&lt;/p&gt;
&lt;p&gt;因为上述MRP假设状态转移是固定的, 而实际情况状态转移是由Agent的行动导致的, 所以MRP还无法建模. 所以要引入动作变量, 拓展为马尔可夫决策过程(MDP). 强化学习的目的是让Agent如何通过动作获得最大的累积奖励, 所以随波逐流的MRP就不能满足要求了. 但是其中的状态价值等概念是可以参考的 -- 这时因为引入策略函数, 就可以将动作a从变量中移除, 转换为仅含概率和原本的状态价值的式子. 所以, 当然, 我们可以经过同样的过程, 由贝尔曼方程的方法推导出其递归形式.&lt;/p&gt;
&lt;p&gt;但是, 我们并不能直接按照之前的方法求解, 因为$\pi$ 本身就是需要优化的对象, 不可能直接得到最佳策略$\pi^*$ , 因此, 我们的思路又转变到了如何获得最佳策略 (即上述三.8的问题, 我们要对马尔可夫决策过程进行控制).&lt;/p&gt;
&lt;p&gt;在这里, 我们想到的方法是按照前面的方式进行策略评估得到状态的价值函数V, 然后用贪心的策略最大化式子里面的Q, 又得到新的V, 如此迭代下去, 直到收敛之后就得到了最佳的策略. 我们可以证明, 每一步都采用贪心最大化Q值就能得到最大的V值, 也就是贝尔曼最优方程.&lt;/p&gt;
&lt;p&gt;还没完, 上述步骤仅仅说明了什么情况下$Q^&lt;em&gt;$或者说$V^&lt;/em&gt;$ 最大, 要知道同一个价值可能也有不同的策略. 因此我们还要在其中抽取最大的策略. 这里有两种做法 (还有一个穷举), 分别是策略迭代和价值迭代. 前者根据贝尔曼最优方程的推导过程自然而然得到一个Q的递推形式, 而贪心的过程被记录在Q表上, 查询Q表就可以得到策略; 后者则是将Q的递推转化为V的递推, 做相似的事情.&lt;/p&gt;
</content:encoded></item><item><title>FastAPI 参数校验：Query、Path、Body、Cookie、Header</title><link>https://owen571.top/posts/study/fastapi/04-fastapi-%E5%8F%82%E6%95%B0%E6%A0%A1%E9%AA%8C%E4%B8%8E%E8%AF%B7%E6%B1%82%E6%9D%A5%E6%BA%90/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/04-fastapi-%E5%8F%82%E6%95%B0%E6%A0%A1%E9%AA%8C%E4%B8%8E%E8%AF%B7%E6%B1%82%E6%9D%A5%E6%BA%90/</guid><description>把 Query、Path、Body、Cookie、Header 统一进一个心智模型：参数从哪里来，以及怎样利用 Annotated 和 Pydantic 做精细校验。</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;官方教程在这里会连续切出 &lt;code&gt;Query&lt;/code&gt;、&lt;code&gt;Path&lt;/code&gt;、&lt;code&gt;Body&lt;/code&gt;、&lt;code&gt;Cookie&lt;/code&gt;、&lt;code&gt;Header&lt;/code&gt; 好几页，第一次读容易觉得“怎么又来一个函数”。更顺的理解方式是：它们其实都在回答同一个问题，只是参数来源不同。&lt;/p&gt;
&lt;h2&gt;1. &lt;code&gt;Query&lt;/code&gt;、&lt;code&gt;Path&lt;/code&gt;、&lt;code&gt;Body&lt;/code&gt; 的真正作用&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import FastAPI, Query, Path

app = FastAPI()


@app.get(&quot;/items/&quot;)
async def read_items(
    q: Annotated[str | None, Query(max_length=50, description=&quot;随便传个字符串&quot;)] = None,
):
    results = {&quot;items&quot;: [{&quot;item_id&quot;: &quot;Foo&quot;}, {&quot;item_id&quot;: &quot;Bar&quot;}]}
    if q:
        results.update({&quot;q&quot;: q})
    return results
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 &lt;code&gt;Query(...)&lt;/code&gt; 做了两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;告诉 FastAPI：这个参数来自查询字符串&lt;/li&gt;
&lt;li&gt;顺手附带额外约束和文档信息&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Path()&lt;/code&gt; 和 &lt;code&gt;Body()&lt;/code&gt; 也是同样的模式，只是来源不同。&lt;/p&gt;
&lt;h2&gt;2. &lt;code&gt;Annotated&lt;/code&gt; 的意义&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;q: Annotated[str | None, Query(max_length=50)] = None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以把它拆成两层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;真正的数据类型是 &lt;code&gt;str | None&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;额外的校验规则和来源说明放在 &lt;code&gt;Query(...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样“类型”和“元信息”就放在了一起，读起来会比老写法更清楚。&lt;/p&gt;
&lt;h2&gt;3. 常见约束：长度、范围、别名、弃用&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Query&lt;/code&gt;、&lt;code&gt;Path&lt;/code&gt;、&lt;code&gt;Body&lt;/code&gt; 支持一大批相似的约束参数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;max_length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;min_length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pattern&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gt&lt;/code&gt; / &lt;code&gt;ge&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lt&lt;/code&gt; / &lt;code&gt;le&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alias&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deprecated&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;include_in_schema&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/p_items/{item_id}&quot;)
async def read_items(
    item_id: Annotated[int, Path(title=&quot;我是一个title&quot;, ge=1)],
    q: Annotated[str | None, Query(alias=&quot;item-query&quot;)] = None,
):
    results = {&quot;item_id&quot;: item_id}
    if q:
        results.update({&quot;q&quot;: q})
    return results
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;item_id&lt;/code&gt; 必须大于等于 1&lt;/li&gt;
&lt;li&gt;对外暴露的查询参数名是 &lt;code&gt;item-query&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. 自定义校验：&lt;code&gt;AfterValidator&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;有些规则不是 &lt;code&gt;gt&lt;/code&gt;、&lt;code&gt;max_length&lt;/code&gt; 这种现成参数能覆盖的，这时可以接 Pydantic 验证器。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import random
from pydantic import AfterValidator

data = {
    &quot;isbn-9781529046137&quot;: &quot;The Hitchhiker&apos;s Guide to the Galaxy&quot;,
    &quot;imdb-tt0371724&quot;: &quot;The Hitchhiker&apos;s Guide to the Galaxy&quot;,
    &quot;isbn-9781439512982&quot;: &quot;Isaac Asimov: The Complete Stories, Vol. 2&quot;,
}


def check_valid_id(id: str):
    if not id.startswith((&quot;isbn-&quot;, &quot;imdb-&quot;)):
        raise ValueError(&apos;Invalid ID format, it must start with &quot;isbn-&quot; or &quot;imdb-&quot;&apos;)
    return id


@app.get(&quot;/v_items/&quot;)
async def read_items(
    id: Annotated[str | None, AfterValidator(check_valid_id)] = None,
):
    if id:
        item = data.get(id)
    else:
        id, item = random.choice(list(data.items()))
    return {&quot;id&quot;: id, &quot;name&quot;: item}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一步很重要，因为它说明 FastAPI 并不只支持“表面上的参数约束”，而是可以自然接入 Pydantic 更细的校验能力。&lt;/p&gt;
&lt;h2&gt;5. 用模型承接一整组查询参数&lt;/h2&gt;
&lt;p&gt;查询参数一多，散着写会越来越乱。这个时候可以把它们建成一个模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated, Literal
from fastapi import Query
from pydantic import BaseModel, Field


class FilterParams(BaseModel):
    model_config = {&quot;extra&quot;: &quot;forbid&quot;}

    limit: int = Field(100, gt=0, le=100)
    offset: int = Field(0, ge=0)
    order_by: Literal[&quot;created_at&quot;, &quot;updated_at&quot;] = &quot;created_at&quot;
    tags: list[str] = []


@app.get(&quot;/items/&quot;)
async def read_items(filter_query: Annotated[FilterParams, Query()]):
    return filter_query
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段非常值得记，因为它把“查询参数”也推进了结构化建模这一层。&lt;/p&gt;
&lt;h2&gt;6. Cookie 和 Header 其实还是同一个模式&lt;/h2&gt;
&lt;p&gt;它们看起来像两个新知识点，本质上还是同一个问题：参数从哪里来。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import Cookie, FastAPI, Header

app = FastAPI()


@app.get(&quot;/items/&quot;)
async def read_items(session_id: Annotated[str | None, Cookie()] = None):
    return {&quot;session_id&quot;: session_id}


@app.get(&quot;/h_items/&quot;)
async def read_items(user_agent: Annotated[str | None, Header()] = None):
    return {&quot;User-Agent&quot;: user_agent}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类来源参数也能继续用模型收起来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel


class CommonHeaders(BaseModel):
    host: str
    save_data: bool
    if_modified_since: str | None = None
    traceparent: str | None = None
    x_tag: list[str] = []


@app.get(&quot;/hs_items/&quot;)
async def read_items(headers: Annotated[CommonHeaders, Header()]):
    return headers
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;7. 到这里最值得留下来的心智&lt;/h2&gt;
&lt;p&gt;这一层最重要的不是把 &lt;code&gt;Query / Path / Body / Cookie / Header&lt;/code&gt; 分别背下来，而是先把统一模式站稳：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数先有类型&lt;/li&gt;
&lt;li&gt;参数再有来源&lt;/li&gt;
&lt;li&gt;参数还可以继续叠加规则&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后面你再看表单、文件上传、依赖注入，理解会快很多，因为底层模式其实没变。&lt;/p&gt;
</content:encoded></item><item><title>小样本多模态微调实战：第一次训练、Loss 曲线与结果复盘</title><link>https://owen571.top/posts/study/fine-tuning/05-%E5%B0%8F%E6%A0%B7%E6%9C%AC%E5%A4%9A%E6%A8%A1%E6%80%81%E5%BE%AE%E8%B0%83%E5%AE%9E%E6%88%98-%E7%AC%AC%E4%B8%80%E6%AC%A1%E8%AE%AD%E7%BB%83%E4%B8%8E%E7%BB%93%E6%9E%9C%E5%A4%8D%E7%9B%98/</link><guid isPermaLink="true">https://owen571.top/posts/study/fine-tuning/05-%E5%B0%8F%E6%A0%B7%E6%9C%AC%E5%A4%9A%E6%A8%A1%E6%80%81%E5%BE%AE%E8%B0%83%E5%AE%9E%E6%88%98-%E7%AC%AC%E4%B8%80%E6%AC%A1%E8%AE%AD%E7%BB%83%E4%B8%8E%E7%BB%93%E6%9E%9C%E5%A4%8D%E7%9B%98/</guid><description>把第一次真正落地的多模态微调实验完整记下来：任务是什么，数据怎么标，参数怎么设，训练结果怎么看，以及为什么它只算“有进展但还远不够好”。</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;前面的几篇都还是“搭心智模型”。这一篇开始真正进入实践：用极少样本先把完整训练流程跑通，再看它到底学到了什么、没学到什么。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一、任务和现实约束&lt;/h1&gt;
&lt;p&gt;这次实验的目标，是让 Qwen2.5-VL 对血液图片做描述和分类。任务并不只是“看出这是一张血迹图”，而是要进一步判断它属于哪一类血液形态。&lt;/p&gt;
&lt;p&gt;原笔记里给出的核心背景是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据涉密，不能上传到公开在线服务&lt;/li&gt;
&lt;li&gt;通用多模态模型能看出“这是红色液体”，但不理解“血液形态”这一任务本身&lt;/li&gt;
&lt;li&gt;样本非常少，只能先做一次小样本试跑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250904172840.png&quot; alt=&quot;任务场景示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;所以这次训练的目的，不是一步到位得到高精度模型，而是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;先把完整流程跑通，并尽快暴露问题。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;二、数据怎么标出来&lt;/h1&gt;
&lt;p&gt;为了提高标注效率，原笔记里先用 &lt;code&gt;clip-vit-large-patch14&lt;/code&gt; 做了一层预处理，然后配了一个交互式 Flask 标注工具，用来不断补全 &lt;code&gt;conversations&lt;/code&gt; 字段。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250904181126.png&quot; alt=&quot;标注界面&quot; /&gt;&lt;/p&gt;
&lt;p&gt;最终生成的就是多模态 ShareGPT 风格数据集：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250904181607.png&quot; alt=&quot;生成后的 conversations&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在这次试验里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;总样本量非常少&lt;/li&gt;
&lt;li&gt;五个小类各自保留 2 个作为测试&lt;/li&gt;
&lt;li&gt;其余样本进入训练&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这本身就决定了结果不会太稳定，但也正因为如此，它特别适合用来观察流程问题。&lt;/p&gt;
&lt;h1&gt;三、第一次训练时怎么选参数&lt;/h1&gt;
&lt;p&gt;训练前先统计了 token 长度，确认大部分数据都在 1000 以内，所以 &lt;code&gt;cutoff_len=2048&lt;/code&gt; 足够。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905190619.png&quot; alt=&quot;长度统计&quot; /&gt;&lt;/p&gt;
&lt;p&gt;再结合前面那篇参数选择的经验，第一次训练采用了下面这组设置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;num_train_epochs=8&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;per_device_train_batch_size=1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gradient_accumulation_steps=1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;learning_rate=5e-5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lora_rank=8&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lora_alpha=16&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;validation split = 0&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;参数面板如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905195807.png&quot; alt=&quot;参数设置&quot; /&gt;&lt;/p&gt;
&lt;p&gt;训练命令的核心部分如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llamafactory-cli train \
  --stage sft \
  --do_train True \
  --model_name_or_path /data/llm/Qwen2.5-VL/Qwen/Qwen2___5-VL-7B-Instruct \
  --finetuning_type lora \
  --template qwen2_vl \
  --dataset blood_image \
  --cutoff_len 2048 \
  --learning_rate 5e-05 \
  --num_train_epochs 8.0 \
  --per_device_train_batch_size 1 \
  --gradient_accumulation_steps 1 \
  --optim adamw_torch \
  --lora_rank 8 \
  --lora_alpha 16 \
  --lora_dropout 0 \
  --lora_target all \
  --freeze_vision_tower True \
  --freeze_multi_modal_projector True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原笔记里对这组选择的直觉其实很清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;样本少，所以 batch 先取小，尽量让模型多“看细节”&lt;/li&gt;
&lt;li&gt;LoRA 还是先从保守配置开始&lt;/li&gt;
&lt;li&gt;验证集先不开，先看训练能不能跑通、Loss 是否合理&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;四、第一次训练出来了什么&lt;/h1&gt;
&lt;p&gt;训练过程如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905201037.png&quot; alt=&quot;第一次训练结果&quot; /&gt;&lt;/p&gt;
&lt;p&gt;其中一个非常有意思的点，是 &lt;code&gt;Total optimization steps = 112&lt;/code&gt; 并不是手工直接设的，而是由参数推出来的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;双卡训练&lt;/li&gt;
&lt;li&gt;每卡 batch size = 1&lt;/li&gt;
&lt;li&gt;总 batch = 2&lt;/li&gt;
&lt;li&gt;梯度累积 = 1&lt;/li&gt;
&lt;li&gt;27 个训练样本，&lt;code&gt;ceil(27 / 2) = 14&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;训练 8 轮，&lt;code&gt;14 × 8 = 112&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这个计算过程其实很适合刚接触训练流程时建立感知：&lt;br /&gt;
很多日志里的数字，不是神秘参数，而是别的参数共同决定出来的。&lt;/p&gt;
&lt;h1&gt;五、显存占用怎么估&lt;/h1&gt;
&lt;p&gt;原笔记里还做了一个很实用的粗估：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;基础模型权重：Qwen2.5-VL-7B，BF16 大约 14GB&lt;/li&gt;
&lt;li&gt;框架开销：约 1GB&lt;/li&gt;
&lt;li&gt;LoRA 适配器：约 0.5GB&lt;/li&gt;
&lt;li&gt;激活值：约 2.5GB&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;合起来单卡大概在 18GB 左右。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905205114.png&quot; alt=&quot;显存占用对比&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这个估算和实际结果已经相当接近，说明对训练资源的判断是比较可靠的。&lt;/p&gt;
&lt;h1&gt;六、Loss 曲线怎么看&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905210206.png&quot; alt=&quot;Loss 曲线&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这条曲线整体是符合预期的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;有下降&lt;/li&gt;
&lt;li&gt;在逐渐变缓&lt;/li&gt;
&lt;li&gt;没出现明显发散&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以至少可以说明：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;训练过程本身没有明显跑崩。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;原笔记还顺手总结了几类常见坏曲线：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Loss 居高不下：可能学习率太小，或者数据噪声太大&lt;/li&gt;
&lt;li&gt;收敛很慢：可能轮数不够&lt;/li&gt;
&lt;li&gt;上下震荡：可能学习率过大或 batch 太小&lt;/li&gt;
&lt;li&gt;很早就卡住：可能学习率衰减过低、局部最优或过拟合&lt;/li&gt;
&lt;li&gt;下降后又回升：通常要怀疑数据问题&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这部分很重要，因为它提醒了一件事：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;曲线正常，不等于任务完成得好。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;七、训练后模型表现怎样&lt;/h1&gt;
&lt;p&gt;训练后的关键输出是 LoRA adapter 文件。加载方式本质上就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;保留原始基础模型&lt;/li&gt;
&lt;li&gt;叠加训练得到的 adapter&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905214111.png&quot; alt=&quot;加载 adapter&quot; /&gt;&lt;/p&gt;
&lt;p&gt;真正测试时，模型已经能识别出“被动的 / 重力类”这一层，但还不能稳定完成更细的分类。&lt;/p&gt;
&lt;h2&gt;1. 能识别的情况&lt;/h2&gt;
&lt;p&gt;比较容易区分的滴落型，已经有了一些正确识别：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905214530.png&quot; alt=&quot;滴落型示例&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. 还会混淆的情况&lt;/h2&gt;
&lt;p&gt;在不少样本上，它还是容易混淆类别，或者只回答到大类，不回答细分类：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905214946.png&quot; alt=&quot;混淆示例 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905215016.png&quot; alt=&quot;混淆示例 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;对于接触类血液，也存在明显误判：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905215224.png&quot; alt=&quot;接触类误判 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905215401.png&quot; alt=&quot;接触类误判 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;浸润 / 血泊型也会被误识别成滴落型：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905215536.png&quot; alt=&quot;浸润误判&quot; /&gt;&lt;/p&gt;
&lt;p&gt;甚至在一些外部干扰明显的图片里，它还会先被背景带偏：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250905220238.png&quot; alt=&quot;外部干扰示例&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;八、第一次实验最重要的价值&lt;/h1&gt;
&lt;p&gt;原笔记最后这段复盘其实特别像真实项目刚起步时的状态：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;数据集太少&lt;/li&gt;
&lt;li&gt;Loss 虽然下降，但平滑程度一般&lt;/li&gt;
&lt;li&gt;模型对任务边界感知不够&lt;/li&gt;
&lt;li&gt;分类不够鲁棒，还会自行联想&lt;/li&gt;
&lt;li&gt;回答过于简洁&lt;/li&gt;
&lt;li&gt;类别分布不平衡，滴落型样本偏多&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以这次训练的真正结论不是“它不行”，而是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;训练链路本身是合理的，问题主要暴露在数据设计与任务约束方式上。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也是下一篇要处理的重点：&lt;br /&gt;
不只是“继续训”，而是先把数据和提示方式重构一遍。&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 核心能力 05：Time-travel 重放与分叉</title><link>https://owen571.top/posts/study/langgraph/06-langgraph-%E6%97%B6%E9%97%B4%E6%97%85%E8%A1%8C-replay-%E4%B8%8E-fork/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/06-langgraph-%E6%97%B6%E9%97%B4%E6%97%85%E8%A1%8C-replay-%E4%B8%8E-fork/</guid><description>用检查点做时间旅行：重放历史、从旧状态分叉新路径，以及如何清理越来越多的 checkpoint。</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;LangGraph能力 - 时间旅行 (Time-travel)&lt;/h1&gt;
&lt;p&gt;LangGraph 支持通过检查点实现时间回溯：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;重放：从先前的检查点重新执行。&lt;/li&gt;
&lt;li&gt;分支：从先前的检查点以修改后的状态分叉，探索其他执行路径。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两者均通过从先前检查点恢复运行。检查点之前的节点不会重新执行（结果已保存）。检查点之后的节点会重新执行，包括所有大模型调用、API 请求以及中断（可能产生不同结果）。&lt;/p&gt;
&lt;h2&gt;1. 重放 (Replay)&lt;/h2&gt;
&lt;p&gt;使用先前检查点的配置调用图，从该点开始重放。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-3.2m39Gcxz.png&amp;amp;w=1650&amp;amp;h=715&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;使用get_state_history找到你希望从中重放的检查点，然后使用该检查点的配置调用invoke：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.graph import StateGraph, START
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict, NotRequired
import uuid

class State(TypedDict):
    topic: NotRequired[str]
    joke: NotRequired[str]


def generate_topic(state: State):
    return {&quot;topic&quot;: &quot;socks in the dryer&quot;}


def write_joke(state: State):
    return {&quot;joke&quot;: f&quot;Why do {state[&apos;topic&apos;]} disappear? They elope!&quot;}


checkpointer = InMemorySaver()
graph = (
    StateGraph(State)
    .add_node(&quot;generate_topic&quot;, generate_topic)
    .add_node(&quot;write_joke&quot;, write_joke)
    .add_edge(START, &quot;generate_topic&quot;)
    .add_edge(&quot;generate_topic&quot;, &quot;write_joke&quot;)
    .compile(checkpointer=checkpointer)
)

# Step 1: Run the graph
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: str(uuid.uuid4())}}
result = graph.invoke({}, config)

# Step 2: Find a checkpoint to replay from
history = list(graph.get_state_history(config))
# History is in reverse chronological order
for state in history:
    print(f&quot;next={state.next}, checkpoint_id={state.config[&apos;configurable&apos;][&apos;checkpoint_id&apos;]}&quot;)

# Step 3: Replay from a specific checkpoint
# Find the checkpoint before write_joke
before_joke = next(s for s in history if s.next == (&quot;write_joke&quot;,))
replay_result = graph.invoke(None, before_joke.config)
# write_joke re-executes (runs again), generate_topic does not
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里稍微复习一下细节，这里TypedDict让字典定义可以写类型，并且写了NotRequired，所以invoke的时候传入{}也是合法的。如果你有印象，我们在LangChain的invoke中会传入一个Message列表，这个列表可以是AIMessage、HumanMessage等的对象，也可以是content block。invoke聊天图的时候，传入的一定要是state的一部分，比如&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph.invoke({
    &quot;messages&quot;: [
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;你好&quot;}
    ]
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;持久化在前面章节介绍过了，我们用graph.get_state_history(config)会得到一个历史快照的迭代器，list化之后可以拿到一个这个thread_id下的所有历史快照（每个超步保存的一个StateSnapshot），我们这时候就可以看看保存的信息。&lt;/p&gt;
&lt;p&gt;然后，我们用&lt;code&gt;before_joke = next(s for s in history if s.next == (&quot;write_joke&quot;,))&lt;/code&gt;，从列表中找到第一个准备开始写笑话之前的节点，在这个图中指的就是generate_topic，然后我们就可以从这个存档开始继续跑，前面置None不传入信息，后面放入找到的历史快照。&lt;/p&gt;
&lt;h2&gt;2. 分支 (Fork)&lt;/h2&gt;
&lt;p&gt;分叉会从过往的一个检查点创建一个新分支，并修改状态。对先前的检查点调用update_state以创建分叉，随后使用None调用invoke来继续执行。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-9.BMMACv06.png&amp;amp;w=1650&amp;amp;h=1157&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Find checkpoint before write_joke
history = list(graph.get_state_history(config))
before_joke = next(s for s in history if s.next == (&quot;write_joke&quot;,))

# Fork: update state to change the topic
fork_config = graph.update_state(
    before_joke.config,
    values={&quot;topic&quot;: &quot;chickens&quot;},
)

# Resume from the fork — write_joke re-executes with the new topic
fork_result = graph.invoke(None, fork_config)
print(fork_result[&quot;joke&quot;])  # A joke about chickens, not socks
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;graph.update_state(...)会基于旧checkpoint创建新的checkpoint分支，传入历史checkpoint的config，放入要更新的state字段就行了。&lt;/p&gt;
&lt;h2&gt;3. 能力总结&lt;/h2&gt;
&lt;p&gt;时间旅行适合进行调试、人工审核或者分叉试验。当我们想进行正常循环的时候，比如经典的“生成 -&amp;gt; 评估 -&amp;gt; 不满意就继续改”，或者拿官腔说是evaluator-optimizer的时候，可以直接在图上做环就行了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;generator -&amp;gt; evaluator -&amp;gt; conditional edge
                         pass -&amp;gt; END
                         fail -&amp;gt; generator
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果一直用时间回溯，虚拟的未来会越来越多。旧checkpoint还在，新checkpoint继续加进去，内存都会保存到python的进程内存中，越来越臃肿。&lt;/p&gt;
&lt;p&gt;旧的checkpoint我们可以通过两种方式清理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;直接删除整条thread，&lt;code&gt;checkpointer.delete_thread(thread_id)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;用LangSmith或者Agent Server配置TTL&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>PyTorch Tensor、Autograd 与动态计算图</title><link>https://owen571.top/posts/study/pytorch/02-pytorch-tensor-autograd-%E4%B8%8E%E5%8A%A8%E6%80%81%E8%AE%A1%E7%AE%97%E5%9B%BE/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/02-pytorch-tensor-autograd-%E4%B8%8E%E5%8A%A8%E6%80%81%E8%AE%A1%E7%AE%97%E5%9B%BE/</guid><description>真正把 PyTorch 和 NumPy 区分开的，是 Tensor 和自动微分。把形状操作、requires_grad 和动态计算图一次理顺。</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇主要整理自 &lt;code&gt;liuer_pytorch/3.ipynb&lt;/code&gt;，以及 &lt;code&gt;pytorch_learning/pytorch_3.py&lt;/code&gt;、&lt;code&gt;pytorch_learning/pytorch_4.py&lt;/code&gt;。如果说上一节在解决“怎么训练一个模型”，这一节就在解决“PyTorch 为什么能训练模型”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. Tensor 不只是数组&lt;/h2&gt;
&lt;p&gt;课程笔记里有一句很关键的话：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;PyTorch 中的基本数据类型是 Tensor，Tensor 实际上是一个类，有两个重要成员：&lt;code&gt;data&lt;/code&gt; 和 &lt;code&gt;grad&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这句话虽然有点“老表述”，但核心意思没变：&lt;br /&gt;
Tensor 在 PyTorch 里不只是存数值，它还能进入计算图，参与自动求导。&lt;/p&gt;
&lt;p&gt;所以和 NumPy 比起来，Tensor 重要的不是“也能做矩阵运算”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它知道自己是否需要梯度&lt;/li&gt;
&lt;li&gt;它知道自己是怎么被算出来的&lt;/li&gt;
&lt;li&gt;它能沿着计算图反向传播&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 先把最常用的形状操作记住&lt;/h2&gt;
&lt;p&gt;我自己的 &lt;code&gt;pytorch_3.py&lt;/code&gt; 基本就是在熟悉这些操作：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import torch as t

b = t.arange(0, 6)
b = b.view(3, 2)

d = b.unsqueeze(1)
e = b.view(1, 1, 2, 1, 3)
e.squeeze_()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最常见的几个动作是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;view(...)&lt;/code&gt;：重排形状，但不改变元素总数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unsqueeze(dim)&lt;/code&gt;：插入一个长度为 &lt;code&gt;1&lt;/code&gt; 的维度&lt;/li&gt;
&lt;li&gt;&lt;code&gt;squeeze(dim)&lt;/code&gt;：压掉长度为 &lt;code&gt;1&lt;/code&gt; 的维度&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后面做 CNN、RNN、Transformer 时，很多 bug 本质上都不是模型错了，而是 shape 没对上。&lt;/p&gt;
&lt;h2&gt;3. Autograd 的核心：记录计算历史&lt;/h2&gt;
&lt;p&gt;我自己理解 Autograd，最有效的一句话是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;前向传播时，PyTorch 会一边算值，一边把这条计算链记录下来。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这就是所谓的动态计算图。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import torch as t

x = t.randn(3, 4, requires_grad=True)
y = x ** 2 * t.exp(x)
grad_y = t.ones_like(y)
y.backward(grad_y)
print(x.grad)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里发生的事情是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;x&lt;/code&gt; 开启了梯度追踪&lt;/li&gt;
&lt;li&gt;&lt;code&gt;y&lt;/code&gt; 的每一步计算都被记录进图里&lt;/li&gt;
&lt;li&gt;&lt;code&gt;backward()&lt;/code&gt; 从输出往回推，把梯度传回 &lt;code&gt;x&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;为什么 &lt;code&gt;y.backward(...)&lt;/code&gt; 这里要传一个同形状的张量？&lt;br /&gt;
因为 &lt;code&gt;y&lt;/code&gt; 不是标量。标量可以默认把“最终损失对输出的梯度”看成 &lt;code&gt;1&lt;/code&gt;，非标量则需要你显式说明。&lt;/p&gt;
&lt;h2&gt;4. 叶子节点、非叶子节点与梯度&lt;/h2&gt;
&lt;p&gt;这个点我一开始也很容易混：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;叶子节点：通常是我们手动创建、真正想优化的变量&lt;/li&gt;
&lt;li&gt;非叶子节点：中间计算结果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;默认情况下，反向传播结束后，真正会保留梯度的是叶子节点。&lt;br /&gt;
中间变量如果也想看梯度，需要额外处理，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;retain_grad()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;torch.autograd.grad(...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;register_hook(...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这在调试网络时非常有用。&lt;/p&gt;
&lt;h2&gt;5. 动态计算图到底“动态”在哪&lt;/h2&gt;
&lt;p&gt;我很喜欢课程里用条件分支举例这一点。&lt;br /&gt;
PyTorch 的动态图不是预先写死的，而是每次前向传播都重新搭一遍。&lt;/p&gt;
&lt;p&gt;所以像下面这种逻辑是成立的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def f(x):
    result = 1
    for i in x:
        if i.data &amp;gt; 0:
            result = i * result
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不同输入会走不同分支，而计算图会在运行时按真实路径构建出来。&lt;br /&gt;
这也是 PyTorch 在研究和实验里非常舒服的原因之一。&lt;/p&gt;
&lt;h2&gt;6. 哪些时候要关掉梯度&lt;/h2&gt;
&lt;p&gt;不是所有阶段都需要反向传播。&lt;/p&gt;
&lt;p&gt;在这些场景里，&lt;code&gt;with torch.no_grad():&lt;/code&gt; 非常重要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;验证集 / 测试集推理&lt;/li&gt;
&lt;li&gt;纯预测&lt;/li&gt;
&lt;li&gt;不希望保存计算图，节省内存&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;with torch.no_grad():
    predictions = model(inputs)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不是语法洁癖，而是推理阶段的常规操作。&lt;/p&gt;
&lt;h2&gt;7. 这一阶段该记住什么&lt;/h2&gt;
&lt;p&gt;如果只保留最核心的认知，我会记这几句：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Tensor 的价值不只是存数据，而是能进入计算图。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requires_grad=True&lt;/code&gt; 才会开始追踪这条计算链。&lt;/li&gt;
&lt;li&gt;非标量做 &lt;code&gt;backward()&lt;/code&gt; 时，需要明确提供梯度入口。&lt;/li&gt;
&lt;li&gt;PyTorch 的计算图是运行时动态生成的，不是静态写死的。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;把这一层吃透之后，再看 &lt;code&gt;nn.Module&lt;/code&gt;、损失函数和优化器，会明显顺很多。&lt;/p&gt;
</content:encoded></item><item><title>免模型强化学习：DP、MC、TD、SARSA 与 Q-learning</title><link>https://owen571.top/posts/study/reinforce-learning/02-%E5%85%8D%E6%A8%A1%E5%9E%8B%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0-dp-mc-td-q-learning/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/02-%E5%85%8D%E6%A8%A1%E5%9E%8B%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0-dp-mc-td-q-learning/</guid><description>当环境模型未知时，强化学习如何从动态规划走向 Monte Carlo、TD、SARSA 与 Q-learning。</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;上一篇笔记, 已经从“为什么要用RL“ 引领到了“如何用MDP相关理论解决RL问题“的门前, 并介绍了策略迭代和价值迭代两种方法. 但是, 这通常要求我们知道环境的动态模型 (比如状态转移概率P和奖励函数R), 但是在训练一个Agent当中, 我们往往无法获得这个模型. 所以接下来的路径自然就是深入各种&lt;strong&gt;免模型&lt;/strong&gt;RL算法, Q-learning、Policy Gradient等算法, 它们是将LLM与RL结合的关键工具.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;实际上, 因为我们主要学习的是思想, 要侧重理解这个公式的&lt;strong&gt;输入、输出、为什么要用这个公式&lt;/strong&gt;, 另外还有一个就是&lt;strong&gt;代码中何处运用了这个公式&lt;/strong&gt;. 经过笔记一的训练应该已经进入了RL的语境, 所以现在会弱化推导的过程, 至于代码的运用, 将会在后面的笔记中专门实验.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. 进化 -- model-based to model-free&lt;/h1&gt;
&lt;p&gt;在很多实际问题中, 马尔可夫决策过程的模型可能是未知的, 具体而言, 我们不知道状态转移函数与奖励函数. 比如，围棋、雅达利游戏、控制直升机、股票涨跌等问题…… 但是我们仍然想让Agent学习到如何行动, 怎么办呢?&lt;/p&gt;
&lt;p&gt;既然舍弃了建模, 那就需要有东西去替代它的作用, 显而易见, 这个东西就是&lt;strong&gt;数据&lt;/strong&gt;. 读者可以回忆 (回去翻) 笔记(一) 中的标题下小字, 那里提到了蒙特卡洛的算法. 因为这种算法是model-free的, 所以我觉得放在这里介绍比较合适.&lt;/p&gt;
&lt;p&gt;这些数据在概率论中被称为采样 (Sample) , 而在强化学习中通常会被称为经验 (Experience) .&lt;/p&gt;
&lt;h1&gt;二. 动态规划的方法 (Dynamic Programming , DP)&lt;/h1&gt;
&lt;p&gt;在介绍具体的model-free方法之前, 有一点需要解释. 回顾笔记(一)中的MDP决策控制, 我们使用了策略迭代和价值迭代两种方法, 当然, 他们都是model-based算法, 但是要是从算法层面来说明的话, 他们都属于动态规划.&lt;/p&gt;
&lt;p&gt;动态规划适合于解决满足最优子结构和重叠子问题的. 因为我们已经得到了一种迭代的公式, 所以我们可以通过&lt;strong&gt;自举 (bootstrapping)&lt;/strong&gt; 的方法不停地迭代贝尔曼方程, 当最后更新的状态和上一个状态区别不大的时候, 更新就可以停止, 我们就可以输出最新的$V&apos;(s)$ 作为它当前的状态的价值. (注: &lt;strong&gt;自举&lt;/strong&gt;是指更新时采用了估计, 例如动态规划和时序差分都是; 蒙特卡洛则是&lt;strong&gt;采样&lt;/strong&gt;).&lt;/p&gt;
&lt;p&gt;对于简单的MRP过程, 我们可以总结这个过程如下:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026155759.PiJ2_1hj.png&amp;amp;w=854&amp;amp;h=236&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;而如果引入智能体的动作成为MDP, 那就是笔记(一)中介绍的两种迭代: 策略迭代和价值迭代了.&lt;/p&gt;
&lt;p&gt;再提一嘴, 不是说model-based的方法就一定不好, 也不是说只有上面说过的两种, 但是他们都是比较基础的开端. 近些年也有继续在model-based领域深挖的, &lt;strong&gt;基于模型的强化学习 (Model-Based Reinforce Learning, MBRL)&lt;/strong&gt;, 这里提供一个工具库: &lt;a href=&quot;https://github.com/facebookresearch/mbrl-lib&quot;&gt;facebookresearch/mbrl-lib: Library for Model Based RL&lt;/a&gt; . 该领域致力于通过数据估计出模型, 继而进行强化学习, 而不是直接用数据.&lt;/p&gt;
&lt;h1&gt;三. 蒙特卡洛方法 (Monte Carlo, MC)&lt;/h1&gt;
&lt;h2&gt;1.  MRP的MC&lt;/h2&gt;
&lt;p&gt;同样, 我们先用MRP这个随波逐流的过程来看MC的价值评估方法, 借此来说明MC的思想. 当得到一个马尔可夫奖励过程后, 我们从某个状态开始, 把agent放在状态转移矩阵里面, 让它”随波逐流”, 这样就会产生一个轨迹. 产生一个轨迹之后, 就会得到一个奖励, 那么直接把折扣的奖励即回报$g$ 算出来之后, 积累起来得到回报$G_t$. 当累积到一定数量的轨迹之后, 我们直接用$G_t$ 除以轨迹数量, 就会得到某个状态的价值.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251026160259.BstxOuQ5.png&amp;amp;w=732&amp;amp;h=312&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. MDP的MC&lt;/h2&gt;
&lt;p&gt;通过上述例子我们就可以知道, MC是通过采样轨迹代替概率轨迹, 采样轨迹奖励的均值代替奖励函数. 这实际上就是依赖于大数定律: 只要我们获得足够多的轨迹, 就可以趋近于价值函数 (因为价值函数的定义就是用期望 ), 即当 $N(s) \rightarrow \infty$ 时, $V(s) \rightarrow V_\pi(s)$ . 虽然我们不能通过迭代求解贝尔曼方程的方法得到价值函数, 但是我们仍然可以用采样来做&lt;strong&gt;策略评估&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;(1) MC Basic&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;注意, 本算法的效果极差, 基本是没法用的状态. 但是却是后面优化的起点, 并且非常清晰的揭示了如何从model-based跨向model-free, 所以必须首先介绍.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;现在需要正式跨向model-free, 通过MRP中的MC我们已经可以知道如何通过采样来近似出概率从而得到价值函数. 但是在更复杂的MDP中, 策略不是能通过采样总结出来的, 而是要主动选择的. 因此, 我们的想法是, &lt;strong&gt;回到策略迭代的算法中, 把里面依赖模型的算法替换掉, 从而得到MDP中的model-free算法&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;首先回顾策略迭代的两个步骤, 策略评估和策略更新. 当时我们是借用Q值的迭代, 并证明了最优情况下的V就是采取最优行动Q的价值. 这里Q值非常关键, 我们回归得出Q值的地方, Q值最原始最基本的定义是学习笔记(一)里的6.6, 当时我们由于是知道奖励函数和状态转移概率的, 所以我们使用贝尔曼公式的工具, 直接推到出了其公式6.8. 实际上, 这里前面的$R(s,a)$ 部分还能继续展开, 因为它是累积的总汇报, 完全写开之后就是这种形式:
$$
Q(s,a)=\sum_rp(r|s,a)r+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)V\left(s^{\prime}\right) \tag{2.1.1}
$$
然后, 模型的更新依赖于Q值. 继续推导, 可以得到式2.1.2表示Q可以由迭代来更新, 然后得到的更新策略的方法就是让Q更大:
$$
Q^{&lt;em&gt;}(s,a)==\sum_rp(r|s,a)r+\gamma\sum_{s^{\prime}\in S}p\left(s^{\prime}\mid s,a\right)\max_{a}Q^{&lt;/em&gt;}(s^{\prime},a^{\prime})\tag{2.1.2}
$$
$$
\pi_{i+1}(s)=\underset{a}{\operatorname*{\arg\max}}Q_{\pi_i}(s,a) \tag{2.1.3}
$$
策略迭代依赖于Q值, Q值依赖于贝尔曼公式道出的递归式, 递归式的求解依赖于动态规划的方法…… 本来是严丝合缝的逻辑, 但是目前, 环境未知, 自然就不可能得到p和r, 这种方法作废.&lt;/p&gt;
&lt;p&gt;那么现在回到最原始的定义 (上一章的式6.6), 它目前还不依赖于模型
$$
Q_\pi(s,a)=\mathbb{E}&lt;em&gt;\pi\left[G_t\mid s_t=s,a_t=a\right] \tag{2.1.4}
$$
这是一个随机变量的期望值进行估计的过程, 或者说这是一个&lt;strong&gt;均值估计&lt;/strong&gt;的过程. 而MC就可以求解, 其中$g^{(i)}$ 是对随机变量的采样, 用来估计$G_t$ :
$$
Q&lt;/em&gt;{\pi_k}(s,a) = \mathbb{E}[G_t|s_t = s, a_t = a] \approx \frac{1}{N} \sum_{i=1}^{N} g^{(i)}(s,a) \tag{2.1.5}
$$
现在我们来梳理一下算法的过程, 首先我们会给出一个初始策略$\pi_0$ , 并且在第k步迭代中, 会有以下两步:
Step1: policy evaluation. 计算得到所有(s, a)的Q值, 方法就是之前说过的MC采样.
Step2: policy improvement. 第二步和策略迭代算法一样, 选出Q表中最大的值, 开始迭代.&lt;/p&gt;
&lt;p&gt;我们可以说, MC Basic算法就是Policy Iteration算法的一个变形, 将其基于模型计算Q值的部分改为了基于采样估计. 另外, Policy Iteration中是先计算V再转成Q, 而MC Basic就是直接估计Q, 这是因为V转化成Q的过程也依赖于模型, 这是肯定不行的. 详见笔记(一)的马尔可夫决策过程控制.&lt;/p&gt;
&lt;h3&gt;(2) MC Exploring Starts&lt;/h3&gt;
&lt;p&gt;MC Basic虽然思想直观, 但是却非常低效, 所以我们对其进行推广, 让其更高效.&lt;/p&gt;
&lt;h3&gt;(3) MC $\epsilon$ -Greedy&lt;/h3&gt;
&lt;p&gt;1-$\epsilon$ 的概率按照Q函数执行动作, $\epsilon$ 概率的可能会随机探索. 通常情况下, $\epsilon$ 是一个比较小的值. 数学上可以证明, 任意$\epsilon$ 贪心策略$\pi&apos;$ 都是对$\pi$ 对改进, 优化是单调的.&lt;/p&gt;
&lt;h1&gt;四. 时序差分学习 (temporal-difference learning, TD)&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;TD (时序差分) 学习是RL中非常经典的算法, 它结合了动态规划和蒙特卡洛的优点, 实现了单步更新. Q-learning和SARSA是TD学习的典型代表, 前者属于离策略(off-policy)学习, 后者则是同策略(on-policy)方法.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. DP, MC和TD&lt;/h2&gt;
&lt;p&gt;到现在, 我们已经学完了DP和MC, 知道了DP和MC的区别最大在于是否model-based. 但是TD和这两者的区别是什么呢. 接下来, 我们可以从统一的视角, 来看一看这三种算法更新的备份图:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251030170508.Srhi8ZVm.png&amp;amp;w=1480&amp;amp;h=368&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
如上图, 从左到右分别是DP, MC, TD (这里是TD(0), 即单步更新) 的视角. DP通过递推相加, 每一个节点都会被计算到; MC每次采样完一整条轨迹. 而TD则是走一步(或几步), 就会对未来的值进行估计.&lt;/p&gt;
&lt;p&gt;时序差分的目的, 就死后对于某个给定的策略$\pi$ , 在线计算出它的状态价值函数$V_\pi$, 即一步一步算. 最简单的算法是&lt;strong&gt;一步时序差分(one-step TD), 即TD(0)&lt;/strong&gt;, 它每走一步都更新一次:
$$
V(s_t) \leftarrow V(s_t) + \alpha (r_{t+1} + \gamma V(s_{t+1}) - V(s_t)) \tag{4.1.1}
$$
上式中, $\alpha$ 是学习率, 而$r_{t+1} + \gamma V(s_{t+1})$ 是估计回报, 也可以称为&lt;strong&gt;时序差分目标(TD Target)&lt;/strong&gt;, 我们减去和目标的差距, 也被称为&lt;strong&gt;TD Error&lt;/strong&gt;, 对价值函数进行软更新, 以此来不断达到逼近目标.&lt;/p&gt;
&lt;p&gt;我们可以看出, 时序差分实际上是一种估计. 首先它同样对期望值采样, 然后最重要的是它使用的是当前估计的V而不是真实的V.&lt;/p&gt;
&lt;p&gt;对TD进行推广, 如果调整步数 (step), 就可以变成&lt;strong&gt;n步差分算法 (n-step TD)&lt;/strong&gt;. n=1的时候, 就是上述提到的TD(0)或者直接称TD算法, 而当n趋向于无穷, 实际上就就是MC算法.&lt;/p&gt;
&lt;p&gt;通过调整步数, 可以进行MC方法和TD方法之间的权衡. 上述方法也被称为基于&lt;strong&gt;state value的TD算法&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;2. Sarsa&lt;/h2&gt;
&lt;p&gt;Sarsa时一种&lt;strong&gt;同策略时序差分算法 (On-Policy)&lt;/strong&gt;,  也就是说, 它只有一个Q表来实现, 优化和选择都在上面.&lt;/p&gt;
&lt;p&gt;Sarsa算法做出的改变很简单, 它把原本TD更新V的过程, 改为了更新Q, 或者说, Sarsa直接估计Q表,  即:
$$
Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha [r_{t+1} + \gamma Q(s_{t+1},a_{t+1}) - Q(s_t,a_t)] \tag{4.2.1}
$$
由于每次更新函数值需要知道目前的状态, 当前的动作, 奖励, 下一步的状态, 下一步的动作, 即$(s_t,a_t,r_{t+1},s_{t+1},a_{t+1})$ , 所以取首字母就构成了Sarsa算法.&lt;/p&gt;
&lt;p&gt;Sarsa同样有单步和n步之分, 依据step. 如果给Q机上资格衰减参数$\lambda$ , 就会成为Sarsa($\lambda$) 策略.&lt;/p&gt;
&lt;h2&gt;3. Q-learning&lt;/h2&gt;
&lt;p&gt;相比于Sarsa, Q-learning采用的是&lt;strong&gt;异策略算法(Off-Policy).&lt;/strong&gt; 在它学习的过程中, 有两种不同的策略, &lt;strong&gt;目标策略(target policy)&lt;/strong&gt; 和 &lt;strong&gt;行为策略(behavior policy)&lt;/strong&gt;. 我们可以进行直观的比喻, 前者相当于军师的角色, 后者相当于士兵. 士兵的按照自己的策略探索环境, 用$\mu$ 表示, 然后探索出来的轨迹/数据再交给军师, 而且交出的数据中不需要像Sarsa一样包含$a_{t+1}$ .&lt;/p&gt;
&lt;p&gt;因为学习策略很多时候太“胆小”了,总倾向于选择目前的最优, 所以有了探索策略.&lt;/p&gt;
&lt;p&gt;异策略学习有很多好处:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;可以用探索学习来学到最佳策略, 学习效率高&lt;/li&gt;
&lt;li&gt;可以&lt;strong&gt;学习其他智能体的动作&lt;/strong&gt;, 进行模仿学习&lt;/li&gt;
&lt;li&gt;可以重用旧的策略产生轨迹, 节省资源&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当然, 以上的优势主要在加入经验回放后才能体现出来, 对于朴素Q-learning来说, 异策略同样是有好处的.&lt;/p&gt;
&lt;p&gt;现在我们来详细介绍Q学习. Q学习在目标策略$\pi$ 上直接采用贪心策略, 按照从Q表里选择最大的来进行. 行为策略$\mu$ 可以是随机的策略, 我们采用$\epsilon$ 贪心方法.&lt;/p&gt;
&lt;p&gt;Q学习的增量表达形式如下, 就形式而言, 其与Sarsa非常相似, 但是要看仔细, 这里的目标是不一样的.
$$
Q(s_t, a_t) \leftarrow Q(s_t, a_t) + \alpha \left[ r_{t+1} + \gamma \max_a Q(s_{t+1}, a) - Q(s_t, a_t) \right] \tag{4.3.1}
$$
对比Sarsa用同一策略选择$a_{t+1}$ 之后再更新Q值, 其目标$r_{t+1} + \gamma \max_a Q(s_{t+1}, a)$ , 使用当前的a中使得Q取得最大的贪心结果, 而不需要$a_{t+1}$ , 也就是说Q学习不需要提前知道下一个动作, 只需要前面的$(s_t,a_t,r_{t+1},s_{t+1})$ .&lt;/p&gt;
&lt;p&gt;当然, 上述更新的式子是隐含异策略的, 只表达了更新时属于完全贪婪, 我们可以将其显式写出, 行为策略$\mu$ 为:
$$
a_t \sim \mu (\cdot|s_t)=\left{
\begin{aligned}
&amp;amp;\text{随机动作，概率 } \epsilon; \
&amp;amp;\operatorname*{argmax}&lt;em&gt;a Q(s_t,a), \text{概率 } 1-\epsilon \end{aligned}
\right. \tag{4.3.2}
$$
学习策略时 (更新Q值时):
$$
\pi(s&lt;/em&gt;{t+1})=argmax_aQ(s_{t+1},a) \tag{4.3.3}
$$
为什么我们要将两个策略分开, 给行为策略选择$\epsilon-greedy$ 算法? 这是其中的核心意义就是, &lt;strong&gt;当前Q值最大的动作不一定是最好的&lt;/strong&gt;, 因为我们得到的信息不完整, 或者说不能采样所有动作, 有的动作可能根本就没有尝试过. $\epsilon-greedy$ 算法就承认了当前的“最好”可能不是真正的“最好”, 所以使用了探索和利用的trade-off, 解决了困境, 有意识探索未知避免陷入局部最优.&lt;/p&gt;
</content:encoded></item><item><title>FastAPI 输出层：响应模型、状态码与数据更新</title><link>https://owen571.top/posts/study/fastapi/05-fastapi-%E5%93%8D%E5%BA%94%E6%A8%A1%E5%9E%8B-%E7%8A%B6%E6%80%81%E7%A0%81%E4%B8%8E%E6%95%B0%E6%8D%AE%E6%9B%B4%E6%96%B0/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/05-fastapi-%E5%93%8D%E5%BA%94%E6%A8%A1%E5%9E%8B-%E7%8A%B6%E6%80%81%E7%A0%81%E4%B8%8E%E6%95%B0%E6%8D%AE%E6%9B%B4%E6%96%B0/</guid><description>从 response_model 开始，把输出约束、状态码、路径操作配置、jsonable_encoder、PUT/PATCH 更新语义一起收进一层。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前面几篇主要在看“请求怎么进来”。这一篇开始把视角转到输出端：接口最终要返回什么、返回到什么程度、文档和数据过滤怎样跟着一起工作。&lt;/p&gt;
&lt;h2&gt;1. &lt;code&gt;response_model&lt;/code&gt; 到底在解决什么&lt;/h2&gt;
&lt;p&gt;最直接的写法，是在返回类型上做类型注解：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None
    tags: list[str] = []


@app.post(&quot;/items/&quot;)
async def create_item(item: Item) -&amp;gt; Item:
    return item


@app.get(&quot;/items/&quot;)
async def read_items() -&amp;gt; list[Item]:
    return [
        Item(name=&quot;Portal Gun&quot;, price=42.0),
        Item(name=&quot;Plumbus&quot;, price=32.0),
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样做的效果不只是“有类型提示”，还包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动生成响应 schema&lt;/li&gt;
&lt;li&gt;自动体现在 OpenAPI 文档里&lt;/li&gt;
&lt;li&gt;返回数据结构不匹配时更早暴露问题&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 返回类型和 &lt;code&gt;response_model&lt;/code&gt; 的关系&lt;/h2&gt;
&lt;p&gt;有时函数真实返回的东西，和你希望文档/过滤层看到的模型不完全一样。这时候可以把 &lt;code&gt;response_model&lt;/code&gt; 写在装饰器上。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None
    tags: list[str] = []


@app.get(&quot;/items/&quot;, response_model=list[Item])
async def read_items() -&amp;gt; Any:
    return [
        {&quot;name&quot;: &quot;Portal Gun&quot;, &quot;price&quot;: 42.0},
        {&quot;name&quot;: &quot;Plumbus&quot;, &quot;price&quot;: 32.0},
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档明确提到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果同时声明了返回类型和 &lt;code&gt;response_model&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;那么 FastAPI 最终会以 &lt;code&gt;response_model&lt;/code&gt; 为准&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;来源：Response Model 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/response-model/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/response-model/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;3. 响应模型不仅描述输出，也会过滤输出&lt;/h2&gt;
&lt;p&gt;这是 &lt;code&gt;response_model&lt;/code&gt; 很值的一点。&lt;/p&gt;
&lt;p&gt;官方文档专门提到“返回类型与数据过滤”这一层：即使函数返回了更多字段，FastAPI 也会按响应模型把不该暴露的字段过滤掉。&lt;br /&gt;
来源：Response Model 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/response-model/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/response-model/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;这也是为什么用户模型常常会拆成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;UserIn&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UserOut&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;或者用继承关系把公共字段提出来。&lt;/p&gt;
&lt;h2&gt;4. &lt;code&gt;response_model_exclude_unset&lt;/code&gt;、&lt;code&gt;include&lt;/code&gt;、&lt;code&gt;exclude&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;如果模型里有很多默认值，但响应里只想保留“真实设置过的字段”，可以这样写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float = 10.5
    tags: list[str] = []


items = {
    &quot;foo&quot;: {&quot;name&quot;: &quot;Foo&quot;, &quot;price&quot;: 50.2},
    &quot;bar&quot;: {&quot;name&quot;: &quot;Bar&quot;, &quot;description&quot;: &quot;The bartenders&quot;, &quot;price&quot;: 62, &quot;tax&quot;: 20.2},
}


@app.get(&quot;/items/{item_id}&quot;, response_model=Item, response_model_exclude_unset=True)
async def read_item(item_id: str):
    return items[item_id]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果只想挑部分字段，也可以用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;response_model_include&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;response_model_exclude&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不过这类写法更适合临时裁剪；真正长期可维护的接口，通常还是拆独立输出模型更清楚。&lt;/p&gt;
&lt;h2&gt;5. 状态码不是附属配置，而是输出语义的一部分&lt;/h2&gt;
&lt;p&gt;你在本地 13、16 这两份笔记里把状态码和路径操作配置单独记出来，这一步其实很值，因为它们就是输出层的一部分。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI, status
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None


@app.post(&quot;/items/&quot;, status_code=status.HTTP_201_CREATED)
async def create_item(item: Item) -&amp;gt; Item:
    return item
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见的几个记忆点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;200&lt;/code&gt;：默认成功&lt;/li&gt;
&lt;li&gt;&lt;code&gt;201&lt;/code&gt;：创建成功&lt;/li&gt;
&lt;li&gt;&lt;code&gt;204&lt;/code&gt;：成功但没有响应体&lt;/li&gt;
&lt;li&gt;&lt;code&gt;400/404&lt;/code&gt;：客户端错误&lt;/li&gt;
&lt;li&gt;&lt;code&gt;500&lt;/code&gt;：服务端错误&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. 路径操作配置：&lt;code&gt;tags&lt;/code&gt;、&lt;code&gt;summary&lt;/code&gt;、&lt;code&gt;description&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;这些参数看起来像“文档修饰”，实际上对大一点的项目非常重要。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from enum import Enum
from fastapi import FastAPI

app = FastAPI()


class Tags(Enum):
    items = &quot;items&quot;
    users = &quot;users&quot;


@app.get(&quot;/items/&quot;, tags=[Tags.items], summary=&quot;读取条目列表&quot;)
async def get_items():
    return [&quot;Portal gun&quot;, &quot;Plumbus&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类配置会直接影响：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/docs&lt;/code&gt; 里的分组&lt;/li&gt;
&lt;li&gt;OpenAPI 中的语义结构&lt;/li&gt;
&lt;li&gt;前后端对接口的认知方式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果应用大起来了，把标签收进 &lt;code&gt;Enum&lt;/code&gt; 会比到处散落字符串稳很多。&lt;/p&gt;
&lt;h2&gt;7. &lt;code&gt;jsonable_encoder&lt;/code&gt; 的位置&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;jsonable_encoder()&lt;/code&gt; 最容易在“更新数据”和“写入数据库前序列化”这两个场景里出现。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from datetime import datetime
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel

fake_db = {}


class Item(BaseModel):
    title: str
    timestamp: datetime
    description: str | None = None


def update_item(id: str, item: Item):
    json_compatible_item_data = jsonable_encoder(item)
    fake_db[id] = json_compatible_item_data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的作用就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把 Pydantic 模型转成更适合 JSON 的结构&lt;/li&gt;
&lt;li&gt;顺手把 &lt;code&gt;datetime&lt;/code&gt;、&lt;code&gt;UUID&lt;/code&gt; 这类类型转换成可序列化形式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你后面要把数据存进数据库、缓存或者文件，这一步非常常见。&lt;/p&gt;
&lt;h2&gt;8. PUT 和 PATCH 的区别&lt;/h2&gt;
&lt;p&gt;你在 18.md 里已经把这一层写出来了，放到输出层来看会更顺：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PUT&lt;/code&gt; 更像整体替换&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PATCH&lt;/code&gt; 更像部分更新&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str | None = None
    description: str | None = None
    price: float | None = None
    tax: float = 10.5
    tags: list[str] = []


items = {
    &quot;foo&quot;: {&quot;name&quot;: &quot;Foo&quot;, &quot;price&quot;: 50.2},
    &quot;bar&quot;: {&quot;name&quot;: &quot;Bar&quot;, &quot;description&quot;: &quot;The bartenders&quot;, &quot;price&quot;: 62, &quot;tax&quot;: 20.2},
}


@app.patch(&quot;/items/{item_id}&quot;, response_model=Item)
async def update_item(item_id: str, item: Item):
    stored_item_data = items[item_id]
    stored_item_model = Item(**stored_item_data)
    update_data = item.model_dump(exclude_unset=True)
    updated_item = stored_item_model.model_copy(update=update_data)
    items[item_id] = jsonable_encoder(updated_item)
    return updated_item
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最核心的一步其实是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;item.model_dump(exclude_unset=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它保证只有用户真的传了的字段才会参与更新，而不会让模型默认值把旧数据覆盖掉。&lt;/p&gt;
&lt;h2&gt;9. 响应层为什么值得单独当一篇看&lt;/h2&gt;
&lt;p&gt;刚开始学 FastAPI，很容易只盯着“能不能把参数收进来”。但真正把接口做稳，靠的是输出端：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;返回什么模型&lt;/li&gt;
&lt;li&gt;过滤掉什么字段&lt;/li&gt;
&lt;li&gt;用什么状态码表达结果&lt;/li&gt;
&lt;li&gt;文档怎么展示&lt;/li&gt;
&lt;li&gt;更新时怎样避免误覆盖&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;输入层决定你怎么接请求，输出层决定你的接口能不能长久稳定。&lt;/p&gt;
</content:encoded></item><item><title>失败复盘与二次优化：system、数据重构与 agent 配合</title><link>https://owen571.top/posts/study/fine-tuning/06-%E5%A4%B1%E8%B4%A5%E5%A4%8D%E7%9B%98%E4%B8%8E%E4%BA%8C%E6%AC%A1%E4%BC%98%E5%8C%96-system-%E6%95%B0%E6%8D%AE%E9%87%8D%E6%9E%84%E4%B8%8E-agent-%E9%85%8D%E5%90%88/</link><guid isPermaLink="true">https://owen571.top/posts/study/fine-tuning/06-%E5%A4%B1%E8%B4%A5%E5%A4%8D%E7%9B%98%E4%B8%8E%E4%BA%8C%E6%AC%A1%E4%BC%98%E5%8C%96-system-%E6%95%B0%E6%8D%AE%E9%87%8D%E6%9E%84%E4%B8%8E-agent-%E9%85%8D%E5%90%88/</guid><description>第一次训练没有达到预期后，真正重要的不是继续堆轮数，而是重构数据、重新定义任务边界，再判断哪些能力应该交给模型，哪些应该交给 agent。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;第一次训练的意义是把问题暴露出来。第二次的重点，就不再是“再训一遍”，而是先重新理解：到底是哪一层出了问题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一、先把问题重新说清楚&lt;/h1&gt;
&lt;p&gt;目标还是同一个：&lt;br /&gt;
对 Qwen2.5-VL 做微调，让它能准确描述图片并完成血液分类任务。&lt;/p&gt;
&lt;p&gt;原笔记里把分类树整理成了这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.
├── 被动的、重力
│   ├── 大量血液自由落体
│   ├── 滴落
│   ├── 接触
│   ├── 浸润、血泊
│   └── 流动
├── 变动的
│   ├── 虫咬的
│   ├── 干缩的
│   ├── 空白区
│   ├── 扩散的
│   ├── 凝固的
│   ├── 顺序的
│   └── 稀释的
└── 溅落的
    ├── 二次作用机理
    ├── 喷射机理
    └── 撞击机理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但第一次实验后暴露出来的问题很明确：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;数据太少&lt;/li&gt;
&lt;li&gt;类别不平衡&lt;/li&gt;
&lt;li&gt;模型对分类任务的“使命感”不够强&lt;/li&gt;
&lt;li&gt;会自行联想出不存在的类别&lt;/li&gt;
&lt;li&gt;回答过于简洁&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以第二轮优化的核心不是“多训一点”，而是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如何在小样本前提下，让模型更明确地知道它究竟要做什么。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;二、第一步尝试：把分类规则写进 system&lt;/h1&gt;
&lt;p&gt;这一步的想法非常自然：&lt;br /&gt;
既然模型不知道分类边界，那就把分类标准显式写给它。&lt;/p&gt;
&lt;p&gt;原笔记把各类血迹的规则进行了细化整理，然后准备写成一段 system 信息，让模型在训练时直接看到这些标准。&lt;/p&gt;
&lt;p&gt;这里的出发点其实完全合理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;样本少&lt;/li&gt;
&lt;li&gt;领域标准复杂&lt;/li&gt;
&lt;li&gt;靠样本自己学出分类边界太难&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以想用 system 给它补规则。&lt;/p&gt;
&lt;h2&gt;1. 但是这一步为什么失败了&lt;/h2&gt;
&lt;p&gt;原笔记里记录得很清楚：&lt;br /&gt;
直接把大段规则写进 system 去训练，效果反而变差，模型开始胡言乱语。&lt;/p&gt;
&lt;p&gt;这件事很值得记，因为它说明：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;“更多任务说明”不一定等于“更好的微调效果”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一个很可能的原因是：&lt;/p&gt;
&lt;p&gt;Qwen2.5-VL-Instruct 这类模型在预训练和指令微调阶段，已经形成了它熟悉的对话格式分布。如果强行引入一套与原有分布不一致、而且很重的 system 结构，模型在小样本上反而更容易学歪。&lt;/p&gt;
&lt;p&gt;所以这里第一次真正感觉到：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;有些约束适合写进模型，有些约束更适合交给外部 agent 或系统逻辑。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;三、真正有效的改动：先重构数据&lt;/h1&gt;
&lt;p&gt;既然“直接灌 system”不行，那就回到更基础的一层：数据本身。&lt;/p&gt;
&lt;h2&gt;1. 把描述写得更细&lt;/h2&gt;
&lt;p&gt;第一次训练里，很多图像描述过于简短，导致模型很难抓住真正能支持分类的细节。&lt;/p&gt;
&lt;p&gt;所以第二轮开始做的第一件事，是把描述写得更具体、更像“真正有助于分类的观察记录”。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250907135057.png&quot; alt=&quot;重构后的样本示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这个改动非常朴素，但往往最有效。因为分类任务不只是要求模型“看到了图像”，而是要求它“看到并描述了对分类有帮助的要点”。&lt;/p&gt;
&lt;h2&gt;2. 把 system 的职责往 agent 迁&lt;/h2&gt;
&lt;p&gt;原笔记里有一句特别关键的判断：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果不框定范围，模型会联想；但直接把范围硬写进训练用 system，又会把模型训练搞乱。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;所以最终想到的折中办法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型微调只学核心任务模式&lt;/li&gt;
&lt;li&gt;更重的任务约束交给外部 agent 的 system 提示去控制&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这其实非常像现在很多真实系统的设计：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;模型参数负责“底层能力”，agent/system 负责“运行时边界”。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;四、第二轮参数怎么改&lt;/h1&gt;
&lt;p&gt;第二次训练并没有彻底推翻第一轮，而是在原有参数上做了几处更有针对性的微调：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--num_train_epochs 10.0
--lora_dropout 0.1
--warmup_steps 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应的直觉分别是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;num_train_epochs&lt;/code&gt; 增加：小样本下让模型多看几遍&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lora_dropout&lt;/code&gt; 增加：给 LoRA 一点正则化，防止过拟合&lt;/li&gt;
&lt;li&gt;&lt;code&gt;warmup_steps&lt;/code&gt; 增加：让学习率更平稳地升起来&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;而有几项则保持不变：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;学习率不变&lt;/li&gt;
&lt;li&gt;&lt;code&gt;per_device_train_batch_size=1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gradient_accumulation_steps=1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;原笔记里的判断是：继续让模型“细着学”，而不是靠更大 batch 去换更平滑的梯度。&lt;/p&gt;
&lt;h1&gt;五、第二轮训练结果&lt;/h1&gt;
&lt;p&gt;调整后再次微调，Loss 曲线如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/fine-tuning/Pasted%20image%2020250907135900.png&quot; alt=&quot;第二轮曲线&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这张图至少说明一点：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二轮不是乱改，而是沿着第一次暴露出来的问题在做针对性修正。&lt;/strong&gt;&lt;/p&gt;
&lt;h1&gt;六、第二轮怎么评估&lt;/h1&gt;
&lt;p&gt;后面没有直接让微调模型裸跑，而是用了一个快速搭起来的 agent，把：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;微调后的模型&lt;/li&gt;
&lt;li&gt;vLLM 启动出来的服务&lt;/li&gt;
&lt;li&gt;外部 system 提示&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;重新组合到一起，再去跑测试集。&lt;/p&gt;
&lt;p&gt;这个做法本身就很有启发性，因为它说明：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;微调不是一个非黑即白的过程，不一定所有约束都必须写进权重。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;最终在 10 张测试图上的结果是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;正确率约 50%&lt;/li&gt;
&lt;li&gt;流动型识别最好&lt;/li&gt;
&lt;li&gt;接触型最容易出错&lt;/li&gt;
&lt;li&gt;回答风格仍然偏简洁&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;原笔记最后把现象概括得很直白：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;特征明显的类别更容易识别&lt;/li&gt;
&lt;li&gt;团聚型血液更容易误判&lt;/li&gt;
&lt;li&gt;接触型容易被识别成流动型&lt;/li&gt;
&lt;li&gt;回答格式化且过于简短&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;七、这次复盘真正留下了什么&lt;/h1&gt;
&lt;p&gt;如果只看数字，50% 正确率显然不理想。&lt;br /&gt;
但如果站在学习和工程角度看，这次复盘其实留下了几条很重要的结论：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;数据设计比单纯加轮数更重要&lt;/li&gt;
&lt;li&gt;system 提示并不是越重越好&lt;/li&gt;
&lt;li&gt;小样本下，任务边界必须明确&lt;/li&gt;
&lt;li&gt;微调与 agent 不必对立，它们可以分工&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以第二轮最大的价值，并不是“已经训好了”，而是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;终于开始知道该把什么交给模型、把什么交给系统。&lt;/strong&gt;&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 核心能力 06：Memory 短期与长期记忆</title><link>https://owen571.top/posts/study/langgraph/07-langgraph-memory-%E7%9F%AD%E6%9C%9F%E4%B8%8E%E9%95%BF%E6%9C%9F%E8%AE%B0%E5%BF%86/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/07-langgraph-memory-%E7%9F%AD%E6%9C%9F%E4%B8%8E%E9%95%BF%E6%9C%9F%E8%AE%B0%E5%BF%86/</guid><description>短期记忆通过 checkpoint 让图“记住”，长期记忆通过 Store 跨线程保存用户信息与语义检索。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;LangGraph能力 - Memory&lt;/h1&gt;
&lt;p&gt;人工智能应用需要记忆来在多次交互间共享上下文。在LangGraph中，你可以添加两种类型的记忆：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;添加短期记忆作为智能体状态的一部分，以实现多轮对话。&lt;/li&gt;
&lt;li&gt;添加长期记忆以跨会话存储用户专属或应用级别的数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. 短期记忆&lt;/h2&gt;
&lt;p&gt;短期记忆，我们应该在LangChain的Short-term Memory和前面的持久化、时间旅行中已经很清晰知道了。现在我们补充一个在生成环境下使用的简单例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = &quot;postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable&quot;
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    builder = StateGraph(...)
    graph = builder.compile(checkpointer=checkpointer)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码是连接Postgres来存checkpoint，它的意义是把 thread 内状态变成可持久、可恢复、可生产使用。不过从LangGraph的概念上来说，这还是属于短期/线程内记忆。&lt;/p&gt;
&lt;p&gt;还有一点，如果你的图中包含子图，只需在编译父图时提供检查点工具即可。LangGraph 会自动将检查点工具传递给子图。&lt;/p&gt;
&lt;h2&gt;2. 长期记忆&lt;/h2&gt;
&lt;p&gt;在前面持久化的章节中，我们说明了Store是负责跨线程共享长期记忆的结构，其中数据按命名空间组织，通过runtime注入读写，并可以支持自然语言query搜索记忆。&lt;/p&gt;
&lt;p&gt;当你使用存储（store）编译图结构时，LangGraph 会自动将存储注入到你的节点函数中。推荐的访问存储方式是通过 Runtime 对象，下面是一个简单示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass
from langgraph.runtime import Runtime
from langgraph.graph import StateGraph, MessagesState, START
import uuid

@dataclass
class Context:
    user_id: str

async def call_model(state: MessagesState, runtime: Runtime[Context]):
    user_id = runtime.context.user_id  
    namespace = (user_id, &quot;memories&quot;)

    # Search for relevant memories
    memories = await runtime.store.asearch(
        namespace, query=state[&quot;messages&quot;][-1].content, limit=3
    )
    info = &quot;\n&quot;.join([d.value[&quot;data&quot;] for d in memories])

    # ... Use memories in model call

    # Store a new memory
    await runtime.store.aput(
        namespace, str(uuid.uuid4()), {&quot;data&quot;: &quot;User prefers dark mode&quot;}
    )

builder = StateGraph(MessagesState, context_schema=Context)
builder.add_node(call_model)
builder.add_edge(START, &quot;call_model&quot;)
graph = builder.compile(store=store)

# Pass context at invocation time
graph.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;hi&quot;}]},
    {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}},
    context=Context(user_id=&quot;1&quot;),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同样，生产级使用的时候，我们会用一个数据库：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.store.postgres import PostgresStore

DB_URI = &quot;postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable&quot;
with PostgresStore.from_conn_string(DB_URI) as store:
    builder = StateGraph(...)
    graph = builder.compile(store=store)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前面提到过的semantic搜索，借助嵌入模型实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.embeddings import init_embeddings
from langgraph.store.memory import InMemoryStore

# Create store with semantic search enabled
embeddings = init_embeddings(&quot;openai:text-embedding-3-small&quot;)
store = InMemoryStore(
    index={
        &quot;embed&quot;: embeddings,
        &quot;dims&quot;: 1536,
    }
)

store.put((&quot;user_123&quot;, &quot;memories&quot;), &quot;1&quot;, {&quot;text&quot;: &quot;I love pizza&quot;})
store.put((&quot;user_123&quot;, &quot;memories&quot;), &quot;2&quot;, {&quot;text&quot;: &quot;I am a plumber&quot;})

items = store.search(
    (&quot;user_123&quot;, &quot;memories&quot;), query=&quot;I&apos;m hungry&quot;, limit=1
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;store.search是一个高层的API，面向LangGraph的长期记忆。InMemoryStore搜索的时候会用 embed_query(...) 生成查询向量，调 _cosine_similarity(...) 计算分数，不过不要随便推广，自然语言搜索是否支持、怎么做，依赖具体 store implementation。LangGraph 官方提供的是 BaseStore 抽象，以及像 InMemoryStore、AsyncSqliteStore、内置 Postgres store 这类实现，如果要用store后端用Milvus，通常要自己实现一个BaseStore。&lt;/p&gt;
&lt;h2&gt;3. 管理短期记忆&lt;/h2&gt;
&lt;p&gt;启用短期记忆后，随着对话变长，&lt;code&gt;messages&lt;/code&gt; 很容易超过模型上下文窗口。这一部分其实已经在LangChain的Short-term Memory中整理过，不过我们再来看看LangGraph的写法，官方给出的常见处理方式有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Trim messages：在调用模型前截断消息，只保留最近一部分上下文。&lt;/li&gt;
&lt;li&gt;Delete messages：从图状态中永久删除某些消息。&lt;/li&gt;
&lt;li&gt;Summarize messages：把更早的消息总结成摘要，再替换原始消息。&lt;/li&gt;
&lt;li&gt;Manage checkpoints：直接查看和管理 thread 的 checkpoint 历史。&lt;/li&gt;
&lt;li&gt;Custom strategies：例如按角色过滤消息、只保留最近几轮工具调用等。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.1 Trim messages&lt;/h3&gt;
&lt;p&gt;最简单的做法是在真正调用模型前，对 &lt;code&gt;messages&lt;/code&gt; 做裁剪。官方推荐可以借助 LangChain 的 &lt;code&gt;trim_messages&lt;/code&gt; 工具，按 token 数量保留最后一段上下文。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain_core.messages.utils import (
    trim_messages,
    count_tokens_approximately,
)

def call_model(state: MessagesState):
    messages = trim_messages(
        state[&quot;messages&quot;],
        strategy=&quot;last&quot;,
        token_counter=count_tokens_approximately,
        max_tokens=128,
        start_on=&quot;human&quot;,
        end_on=(&quot;human&quot;, &quot;tool&quot;),
    )
    response = model.invoke(messages)
    return {&quot;messages&quot;: [response]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中常见参数含义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;strategy=&quot;last&quot;&lt;/code&gt;：优先保留最新消息。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_tokens&lt;/code&gt;：裁剪后的消息总 token 上限。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;start_on=&quot;human&quot;&lt;/code&gt;：尽量让裁剪后的历史从用户消息开始。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;end_on=(&quot;human&quot;, &quot;tool&quot;)&lt;/code&gt;：裁剪边界尽量落在合法消息类型上。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种方法的优点是简单直接，缺点是早期信息会被丢弃。&lt;/p&gt;
&lt;h3&gt;3.2 Delete messages&lt;/h3&gt;
&lt;p&gt;如果你不是“临时裁剪后喂给模型”，而是希望真正从图状态中删掉某些旧消息，可以返回 &lt;code&gt;RemoveMessage&lt;/code&gt;。这会永久修改短期记忆。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import RemoveMessage

def delete_messages(state: MessagesState):
    messages = state[&quot;messages&quot;]
    if len(messages) &amp;gt; 2:
        return {
            &quot;messages&quot;: [RemoveMessage(id=m.id) for m in messages[:2]]
        }
    return {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这种方式要求 &lt;code&gt;messages&lt;/code&gt; 使用 &lt;code&gt;add_messages&lt;/code&gt; reducer，也就是通常使用 &lt;code&gt;MessagesState&lt;/code&gt; 或 &lt;code&gt;messages: Annotated[list, add_messages]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;删除后仍要保证消息历史合法。比如有些模型要求消息历史以 &lt;code&gt;human&lt;/code&gt; 开始；如果前面有 tool call，对应的 &lt;code&gt;ToolMessage&lt;/code&gt; 也不能删乱。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.3 Summarize messages&lt;/h3&gt;
&lt;p&gt;只裁剪和删除会丢失信息，所以更实用的方式是：把旧消息总结成一段摘要，然后只保留最近几条原始消息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import HumanMessage, RemoveMessage
from langgraph.graph import MessagesState

class State(MessagesState):
    summary: str

def summarize_conversation(state: State):
    summary = state.get(&quot;summary&quot;, &quot;&quot;)

    if summary:
        summary_message = (
            f&quot;This is a summary of the conversation to date: {summary}\n\n&quot;
            &quot;Extend the summary by taking into account the new messages above:&quot;
        )
    else:
        summary_message = &quot;Create a summary of the conversation above:&quot;

    messages = state[&quot;messages&quot;] + [HumanMessage(content=summary_message)]
    response = model.invoke(messages)

    delete_messages = [RemoveMessage(id=m.id) for m in state[&quot;messages&quot;][:-2]]
    return {
        &quot;summary&quot;: response.content,
        &quot;messages&quot;: delete_messages,
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个模式的核心思想是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;summary&lt;/code&gt; 单独作为 state 的一个键长期保留。&lt;/li&gt;
&lt;li&gt;新摘要会在旧摘要基础上继续扩展，而不是每次从零总结。&lt;/li&gt;
&lt;li&gt;原始消息不需要全部保留，只保留最近几条高价值上下文即可。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以可以把它理解成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;messages&lt;/code&gt;：保存最近的原始上下文&lt;/li&gt;
&lt;li&gt;&lt;code&gt;summary&lt;/code&gt;：保存更早历史的压缩版本&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.4 管理 checkpoints&lt;/h3&gt;
&lt;p&gt;短期记忆本质上就是 thread 级别的 checkpoint 状态，因此也可以直接通过 checkpoint API 查看。&lt;/p&gt;
</content:encoded></item><item><title>PyTorch 分类任务、Dataset / DataLoader 与训练循环</title><link>https://owen571.top/posts/study/pytorch/03-pytorch-%E5%88%86%E7%B1%BB%E4%BB%BB%E5%8A%A1-dataset-dataloader-%E4%B8%8E%E8%AE%AD%E7%BB%83%E5%BE%AA%E7%8E%AF/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/03-pytorch-%E5%88%86%E7%B1%BB%E4%BB%BB%E5%8A%A1-dataset-dataloader-%E4%B8%8E%E8%AE%AD%E7%BB%83%E5%BE%AA%E7%8E%AF/</guid><description>从逻辑回归、二分类、多分类一路串到 Dataset、DataLoader 和小作业，把真正训练一个分类模型需要的元素放到一条线上。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇主要整理自 &lt;code&gt;liuer_pytorch/5-10.ipynb&lt;/code&gt;。相比前两篇的“训练直觉”和“自动微分”，这里更像真正开始做任务：输入有了标签，输出不再是连续值，而是类别。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 分类和回归最大的不同是什么&lt;/h2&gt;
&lt;p&gt;在线性回归里，我们输出的是一个连续值。&lt;br /&gt;
到了分类任务，输出就不再是“一个实数”，而更像“每个类别的概率分布”。&lt;/p&gt;
&lt;p&gt;课程里对逻辑回归的总结很直接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;虽然叫“回归”，但它解决的是分类问题&lt;/li&gt;
&lt;li&gt;输出要映射到 &lt;code&gt;0-1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;loss 常常用交叉熵&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就是为什么二分类里经常会看到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sigmoid&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BCELoss&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而多分类里经常会看到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;softmax&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CrossEntropyLoss&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 从二分类到多分类&lt;/h2&gt;
&lt;p&gt;这组笔记里，二分类和多分类的任务其实已经很典型了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;糖尿病数据集：二分类&lt;/li&gt;
&lt;li&gt;Titanic 作业：表格分类&lt;/li&gt;
&lt;li&gt;Otto 数据集：多分类&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从学习角度看，它们最有价值的地方不是“数据集本身”，而是让我逐渐看到分类训练的完整链路：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;数据预处理&lt;/li&gt;
&lt;li&gt;定义模型&lt;/li&gt;
&lt;li&gt;定义损失函数&lt;/li&gt;
&lt;li&gt;划分 batch&lt;/li&gt;
&lt;li&gt;训练与评估&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. Dataset 和 DataLoader 为什么重要&lt;/h2&gt;
&lt;p&gt;课程在 &lt;code&gt;7.ipynb&lt;/code&gt; 里专门整理了三个概念：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Epoch&lt;/code&gt;：完整看完一遍全部样本&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Batch Size&lt;/code&gt;：一次前向与反向传播处理多少样本&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Iteration&lt;/code&gt;：一个 epoch 被切成多少次参数更新&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后真正把数据喂给模型的，是 &lt;code&gt;Dataset&lt;/code&gt; 和 &lt;code&gt;DataLoader&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DataLoader&lt;/code&gt; 至少解决了几件很烦但必须做的事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;按 batch 划分数据&lt;/li&gt;
&lt;li&gt;shuffle 打乱顺序&lt;/li&gt;
&lt;li&gt;变成一个可迭代对象&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，它让训练循环终于能写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for epoch in range(epochs):
    for x, y in train_loader:
        optimizer.zero_grad()
        pred = model(x)
        loss = criterion(pred, y)
        loss.backward()
        optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是从“玩具代码”进入“正常训练代码”的关键一步。&lt;/p&gt;
&lt;h2&gt;4. 表格数据任务里，数据清洗不能跳过&lt;/h2&gt;
&lt;p&gt;在 Titanic 那一节里，我觉得最有价值的不是模型本身，而是那份 Pandas 数据清洗速查表。&lt;br /&gt;
因为表格数据任务很少能一上来就直接转 Tensor。&lt;/p&gt;
&lt;p&gt;真正高频的动作包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;df.info()&lt;/code&gt;：看列类型和缺失值&lt;/li&gt;
&lt;li&gt;&lt;code&gt;df.isnull().sum()&lt;/code&gt;：排查空值&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fillna(...)&lt;/code&gt;：填补缺失&lt;/li&gt;
&lt;li&gt;&lt;code&gt;map(...)&lt;/code&gt;：把类别映射成数字&lt;/li&gt;
&lt;li&gt;&lt;code&gt;drop(...)&lt;/code&gt;：删除无用列&lt;/li&gt;
&lt;li&gt;&lt;code&gt;astype(...)&lt;/code&gt;：强制转换类型&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步如果没做，后面 PyTorch 再熟也跑不顺。&lt;/p&gt;
&lt;h2&gt;5. PyTorch 多分类里最常见的误区&lt;/h2&gt;
&lt;p&gt;课程里讲多分类时提到一个很关键的直觉：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;希望输出有竞争性，且大于等于 0，和为 1。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这就是 Softmax 的角色。&lt;br /&gt;
但在真正写 PyTorch 时，一个很常见的坑是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型输出 logits&lt;/li&gt;
&lt;li&gt;损失用 &lt;code&gt;CrossEntropyLoss&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;不需要自己先手动做 softmax&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为 &lt;code&gt;CrossEntropyLoss&lt;/code&gt; 内部已经帮你处理了。&lt;/p&gt;
&lt;h2&gt;6. 这一阶段真正应该掌握什么&lt;/h2&gt;
&lt;p&gt;如果只留最核心的能力，我觉得是下面这些：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;能分清回归、二分类、多分类对应的输出和损失函数。&lt;/li&gt;
&lt;li&gt;知道 &lt;code&gt;Dataset&lt;/code&gt; / &lt;code&gt;DataLoader&lt;/code&gt; 为什么是标准训练入口。&lt;/li&gt;
&lt;li&gt;知道 batch、epoch、iteration 分别在说什么。&lt;/li&gt;
&lt;li&gt;知道真实任务里，数据预处理和特征清洗本来就是训练流程的一部分。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;到这一步，PyTorch 就不只是“会写一个最小例子”了，而是已经开始具备做小任务的基本骨架。&lt;/p&gt;
</content:encoded></item><item><title>RAG 入门：概念、优势与演进路线</title><link>https://owen571.top/posts/study/rag/01-rag-%E7%AE%80%E4%BB%8B%E4%B8%8E%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-rag/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/01-rag-%E7%AE%80%E4%BB%8B%E4%B8%8E%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-rag/</guid><description>先建立 RAG 的最小心智：它解决什么问题、相对微调的边界在哪里，以及 Naive / Advanced / Modular RAG 如何演进。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇是整条 RAG 学习线的起点。先把“为什么需要 RAG”说清楚，后面再去看数据加载、文本分块和向量数据库，就不容易只记工具名，不记系统目标。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 简介&lt;/h1&gt;
&lt;h2&gt;一、什么是RAG&lt;/h2&gt;
&lt;h2&gt;1. 核心定义&lt;/h2&gt;
&lt;p&gt;从本质上讲，RAG（Retrieval-Augmented Generation）是一种旨在解决大语言模型（LLM）“知其然不知其所以然”问题的技术范式。它的核心是将模型内部学到的“参数化知识”（模型权重中固化的、模糊的“记忆”），与来自外部知识库的“非参数化知识”（精准、可随时更新的外部数据）相结合。其运作逻辑就是在 LLM 生成文本前，先通过检索机制从外部知识库中动态获取相关信息，并将这些“参考资料”融入生成过程，从而提升输出的准确性和时效性。&lt;/p&gt;
&lt;h2&gt;2. 技术原理&lt;/h2&gt;
&lt;p&gt;RAG系统实现参数化知识+非参数化结果结合的方法，主要可以分为两步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;检索阶段：通过知识向量化、语义召回等方式寻找非参数化知识&lt;/li&gt;
&lt;li&gt;生成阶段：将检索到的知识整合到上下文，按照预设的Prompt指令，将上下文和问题有效整合，并引导LLM做出可控的、有理有据的文本生成。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 技术演进&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-1.Cqpa-Xvy.png&amp;amp;w=2012&amp;amp;h=1004&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;初级 RAG（Naive RAG）&lt;/th&gt;
&lt;th&gt;高级 RAG（Advanced RAG）&lt;/th&gt;
&lt;th&gt;模块化 RAG（Modular RAG）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;流程&lt;/td&gt;
&lt;td&gt;离线：索引&amp;lt;br&amp;gt;在线：检索 → 生成&lt;/td&gt;
&lt;td&gt;离线：索引&amp;lt;br&amp;gt;在线：… → 检索前 → … → 检索后 → …&lt;/td&gt;
&lt;td&gt;积木式可编排流程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;特点&lt;/td&gt;
&lt;td&gt;基础线性流程&lt;/td&gt;
&lt;td&gt;增加检索前后的优化步骤&lt;/td&gt;
&lt;td&gt;模块化、可组合、可动态调整&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;关键技术&lt;/td&gt;
&lt;td&gt;基础向量检索&lt;/td&gt;
&lt;td&gt;查询重写（Query Rewrite）&amp;lt;br&amp;gt;结果重排（Rerank）&lt;/td&gt;
&lt;td&gt;动态路由（Routing）&amp;lt;br&amp;gt;查询转换（Query Transformation）&amp;lt;br&amp;gt;多路融合（Fusion）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;局限性&lt;/td&gt;
&lt;td&gt;效果不稳定，难以优化&lt;/td&gt;
&lt;td&gt;流程相对固定，优化点有限&lt;/td&gt;
&lt;td&gt;系统复杂性高&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这里的离线指的是提起完成数据预处理。&lt;/p&gt;
&lt;h2&gt;二、为什么要使用RAG&lt;/h2&gt;
&lt;h2&gt;1. RAG vs. 微调&lt;/h2&gt;
&lt;p&gt;在选择具体的技术路径时，一个重要的考量是成本与效益的平衡。通常，我们应优先选择对模型改动最小、成本最低的方案，所以技术选型路径往往遵循的顺序是提示词工程（Prompt Engineering） -&amp;gt; 检索增强生成 -&amp;gt; 微调（Fine-tuning）。&lt;/p&gt;
&lt;p&gt;下图横轴表示LLM优化，纵轴表示上下文优化。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-2.CnHXgL8v.png&amp;amp;w=591&amp;amp;h=363&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;th&gt;RAG 的解决方案&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;静态知识局限&lt;/td&gt;
&lt;td&gt;实时检索外部知识库，支持动态更新&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;幻觉（Hallucination）&lt;/td&gt;
&lt;td&gt;基于检索内容生成，错误率降低&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;领域专业性不足&lt;/td&gt;
&lt;td&gt;引入领域特定知识库（如医疗/法律）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据隐私风险&lt;/td&gt;
&lt;td&gt;本地化部署知识库，避免敏感数据泄露&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;2. RAG的关键优势&lt;/h2&gt;
&lt;p&gt;以下直接照搬All in RAG，看一遍即可：&lt;/p&gt;
&lt;h3&gt;(1) 准确性与可信度的双重提升&lt;/h3&gt;
&lt;p&gt;RAG 最核心的价值在于突破了模型预训练知识的限制。它不仅能补充专业领域的知识盲区，还能通过提供具体的参考材料，有效抑制“一本正经胡说八道”的幻觉现象。论文研究还表明，RAG 生成的内容在具体性和多样性上也显著优于纯 LLM。更重要的是，RAG 具备可溯源性——每一条回答都能找到对应的原始文档出处，这种“有据可查”的特性极大提高了内容在法律、医疗等严肃场景下的可信度。&lt;/p&gt;
&lt;h3&gt;(2) 时效性保障&lt;/h3&gt;
&lt;p&gt;在知识更新方面，RAG 解决了 LLM 固有的知识时滞问题（即模型不知道训练截止日期之后发生的事）。RAG 允许知识库独立于模型进行动态更新——新政策或新数据一旦入库，立刻就能被检索到。这种能力在论文中被称为“索引热拔插”（Index Hot-swapping）——就像给机器人换一张存储卡一样，瞬间切换其世界知识库，而无需重新训练模型，实现了知识的实时在线。&lt;/p&gt;
&lt;h3&gt;(3) 显著的综合成本效益&lt;/h3&gt;
&lt;p&gt;从经济角度看，RAG 是一种高性价比的方案。首先，它避免了高频微调带来的巨额算力成本；其次，由于有了外部知识的强力辅助，我们在处理特定领域问题时，往往可以使用参数量更小的基础模型来达到类似的效果，从而直接降低了推理成本。这种架构也减少了试图将海量知识强行“塞入”模型权重中所需的计算资源消耗。&lt;/p&gt;
&lt;h3&gt;(4) 灵活的模块化可扩展性&lt;/h3&gt;
&lt;p&gt;RAG 的架构具备极强的包容性，支持多源集成，无论是 PDF、Word 还是网页数据，都能统一构建进知识库中。同时，其模块化设计实现了检索与生成的解耦，这意味着我们可以独立优化检索组件（比如更换更好的 Embedding 模型），而不会影响到生成组件的稳定性，便于系统的长期迭代。&lt;/p&gt;
&lt;h2&gt;3. RAG风险评估&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;风险等级&lt;/th&gt;
&lt;th&gt;案例&lt;/th&gt;
&lt;th&gt;RAG 适用性&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;低风险&lt;/td&gt;
&lt;td&gt;翻译/语法检查&lt;/td&gt;
&lt;td&gt;高可靠性&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;中风险&lt;/td&gt;
&lt;td&gt;合同起草/法律咨询&lt;/td&gt;
&lt;td&gt;需结合人工审核&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;高风险&lt;/td&gt;
&lt;td&gt;证据分析/签证决策&lt;/td&gt;
&lt;td&gt;需严格质量控制机制&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>从表格到函数：DQN 与 Value-Based 深度强化学习</title><link>https://owen571.top/posts/study/reinforce-learning/03-dqn-%E4%B8%8E-value-based-%E6%B7%B1%E5%BA%A6%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/03-dqn-%E4%B8%8E-value-based-%E6%B7%B1%E5%BA%A6%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0/</guid><description>把表格型 Q 学习推进到深度网络近似，并串起 DQN、DDQN、PER 等常见改进。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;之前陈述的问题中, 动作都是离散的, 所以可以用表格表达, 但是在实际RL过程中, 很多时候是高纬度的动作, 甚至是无限的动作(一个范围), 这时所有动作都会失效&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. 引入深度网络&lt;/h1&gt;
&lt;p&gt;我们需要在连续的状态和动作空间中计算函数值$Q_\pi(s,a)$, 我们可以用一个函数$Q_\phi(s,a)$ 来近似计算, 称为&lt;strong&gt;价值函数近似 (value funciton approximation)&lt;/strong&gt; :
$$
Q_\phi(\boldsymbol{s},\boldsymbol{a})\approx Q_\pi(\boldsymbol{s},\boldsymbol{a}) \tag{1.1}
$$
函数$Q_\phi(s,a)$ 通常是一个参数为$\phi$ 的函数, 比如神经网络, 其输出为一个实数, 称为&lt;strong&gt;Q网络 (Q-network)&lt;/strong&gt;. 因为Q值本质上是一个实数, 所以我们可以通过这种端到端的方法, 直接计算出Q值.&lt;/p&gt;
&lt;h1&gt;二. 离散动作的DQN&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;深度Q网络 (deep Q-network)&lt;/strong&gt; 是指基于深度学习的Q学习. DQN是value-based算法, 批评家 (Critic) 基于深度网络计算Q. 我们首先介绍三个常用的技巧, 然后在给出DQN算法的更新过程.&lt;/p&gt;
&lt;h2&gt;1. 目标网络&lt;/h2&gt;
&lt;p&gt;DQN与Q-learning的思想没有区别, 只是用神经网络完成了标量Q的输出.我们回顾Q-learning算法, 他的核心思想也是让做策略评估, 让当前的Q更接近于贝尔曼公式递推出来的Q‘. Q-learning的更新式可以写作:
$$
Q(s_t, a_t) \leftarrow Q(s_t, a_t) + \alpha \left[ r_{t+1} + \gamma \max_a Q(s_{t+1}, a) - Q(s_t, a_t) \right] \tag{2.1.1}
$$
这其中, Q-Target为:
$$
Q_{target}=r_{t+1}+\gamma \underset{a}{max}Q(s_{t+1},a) \tag{2.1.2}
$$
对于DQN就是用一个带有参数$\theta^-$ 的目标网络来输出这个值, 所以可以写作:
$$
Q_{target}=r_{t+1}+\gamma \underset{a}{max}Q(s_{t+1},a;\theta^-) \tag{2.1.3}
$$
相对的, 左侧的Q就用一个参数为$\theta$ 的网络来输出, 用于选择动作和计算当前Q值.&lt;/p&gt;
&lt;p&gt;回顾之前的TD方法, 我们用TD error, 加上学习率$\alpha$ 对Q进行软更新. 但是这样其实是不好学习的, 因为每次Q都是更新的, 也就是说, 我们在学习的过程中, 目标也是变动的.&lt;/p&gt;
&lt;p&gt;我们可以举个例子, 在猫抓老鼠当中, 将猫比做Q估计 (左侧), 老鼠比做Q目标 (右侧), 如果Q网络也会动, 就会产生非常奇怪的优化轨迹, 使得训练十分不稳定.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251031135020.CWjroo6w.png&amp;amp;w=1120&amp;amp;h=322&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;所以, 我们可以先把老鼠固定一段时间, 动的不要那么频繁, 比如每五步跑一次, 放到上面的式子中, 就是我们把后面的$Q_\pi\left(s_{t+1},\pi\left(s_{t+1}\right)\right)$ 称为&lt;strong&gt;目标网络&lt;/strong&gt;, 这其中的$\pi(s_{t+1})$ 采用和上一节Q-learning中一样的argmax策略选择a (式4.3.3). 将其固定, 左侧的Q更新, 等到更新一些次数之后(比如100次) 再把参数复制到右边的网络中改变目标值. 因此, 在目标没有改变的期间, 我们仅仅相当于在做一个回归问题, 去靠近一个固定的值:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2FQQ_1762498458273.Bd8Eawr_.png&amp;amp;w=1124&amp;amp;h=846&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;严格来说$r_t$ 应该写为$r_{t+1}$ 比较规范. 回忆一下我们的回报$G(t)$ 也是从t+1开始奖励, 不知道你当时疑惑了没有🤫. 后面图片也有这个问题.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;2. 探索&lt;/h2&gt;
&lt;p&gt;当我们使用Q函数的时候, 策略完全取决于Q函数, 给定一个状态, 我们就穷举所有动作, 来采取让Q值最大的动作. 这里就遇到一个问题: 我们一定要对一个动作进行过采样才能计算出Q值, 然而如果在那个状态没有采样过某动作, 就估测不出它的Q值.&lt;/p&gt;
&lt;p&gt;如果Q是表格, 问题会很严重, 导致根本估不出没见过的s-a对的Q值; 如果是网络, 也会有类似的问题, 假设有三个s-a对, 我们估测其初始值(假设为0,0,0), 然后我们采样了第二对得到了正向的奖励, 变成了(0,1,0), 这样我们以后每次都会选择第二个动作, 但是可能选择两外两个会更好.&lt;/p&gt;
&lt;p&gt;如果我们没有很好探索, 训练就会遇到这种问题. 所以, 我们需要在探索和利用中找到一个trade-off, 这个问题被称为强化学习过程中的&lt;strong&gt;探索-利用窘境(exploration-exploitation dilemma)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;我们通常可以用两个方法来解决它, 首先是我们的老熟人 &lt;strong&gt;$\epsilon$-贪心&lt;/strong&gt;, 我们在model-free的MC方法中就使用了这种优化. 而另一种是&lt;strong&gt;玻尔兹曼探索 (Boltzmann exploration)&lt;/strong&gt;. 我们假设对于所有s-a对, Q值均大于等于0, 那么a选中的概率就和Q成正比. 我们引入温度系数T, 得到下面式子:
$$
\pi(a\mid s)=\frac{\mathrm{e}^{Q(s,a)/T}}{\sum_{a^{\prime}\in A}\mathrm{e}^{Q(s,a^{\prime})/T}} \tag{2.1.1}
$$
其中T为正数. 如果T很大, 所有动作几乎都以等概率选择 (探索); 如果T很小, Q值大的动作更倾向于被选中 (利用). 通过调整T值, 我们可以实现trade-off.&lt;/p&gt;
&lt;h2&gt;3. 经验回放&lt;/h2&gt;
&lt;p&gt;读者也许还记得介绍Q-learning的时候提到过异策略算法的优势, 其中之一就是可以重用旧的采样, 产生轨迹, 节省性能. 我们构建一个&lt;strong&gt;回放缓冲区(replay buffer)&lt;/strong&gt;, 也被称作&lt;strong&gt;回放内存(replay memory)&lt;/strong&gt;. 现有策略$\pi$ 与环境交互多次收集数据, 全部放在buffer中. 回放缓冲区的&lt;strong&gt;经验&lt;/strong&gt;可能来自于不同的策略, 在存满的时候才会丢弃旧的策略.&lt;/p&gt;
&lt;p&gt;有了回放缓冲区之后, 我们会迭代训练Q函数, 在每次迭代里面从回放缓冲区随机挑选一个批量 (batch) 出来, 按照过去的经验去更新Q函数. 所以说, 如果使用了经验回放的技巧, 这个算法也就是异策略算法了.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251031154119.BgJ-ACLJ.png&amp;amp;w=602&amp;amp;h=364&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. 深度Q网络&lt;/h2&gt;
&lt;p&gt;一般的深度Q网络中, 我们初始化两个网络 -- Q和$\hat{Q}$ . 开始两者一样, 然后我们对于每一个时间步, 用探索的算法 (如$\epsilon$-贪心) 选择动作a获得反馈r, 然后我们$(s_t,a_t,r_t,s_{t+1})$ 存储到缓冲区中. 然后我们从缓冲区以批量形式采样, 然后更新Q函数. 我们通过更新Q让其更接近于目标网络$y=r_{i}+\max_{a}\widehat{Q}(s_{i+1},a)$ (回归). 然后每经过C次重置$\hat{Q}=Q$ , 并更新目标.&lt;br /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251031162456.D1TYHPOc.png&amp;amp;w=706&amp;amp;h=338&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;三. 深度Q网络进阶优化&lt;/h1&gt;
&lt;h2&gt;1. 双深度Q网络 (double DQN, DDQN)&lt;/h2&gt;
&lt;p&gt;为什么要提出DDQN ? 这是因为, 在传统的Q网络中, Q值往往是被高估的. 这是因为我们实际在设计更新式子的过程中, 我们实际上就是看哪个a可以得到最大的Q值, 就贪心为目标. 但是, 网络是有误差的, 假设其中一个动作被高估了, 就总会倾向于选择它, 从而使目标总是太大.&lt;/p&gt;
&lt;p&gt;为了解决高估问题, 我们在DDQN设置了两个Q函数. 其中一个与之前一样, 贪心决定动作a, 但是决定之后并不适用这个Q网络计算Q值, 而是用另一个Q‘计算, 也就是:
$$
Q\left(s_t,a_t\right)\longleftrightarrow r_t+Q^{\prime}\left(s_{t+1},\arg\max_aQ\left(s_{t+1},a\right)\right)\tag{3.1.1}
$$
这样一来, 如果Q高估了a, 只要Q‘没有高估, 就还是正常的值; 如果Q’高估了, 也是没问题的, 只要Q不选择这个a就可以. 这种互相制约的网络, 正是DDQN的神奇之处.&lt;/p&gt;
&lt;p&gt;我们针对如下几个游戏中, DDQN和DQN之间的对比, DDQN得到的真正的Q值是要比DQN高的, 所以我们说, DDQN学出来的策略比较强, 实际得到的奖励比较大.&lt;/p&gt;
&lt;p&gt;最上面一行中水平的橙色（对应DQN）和蓝色（对应Double DQN）直线是在学习结束后运行相应智能体，并对从每个访问状态获得的实际折扣回报进行平均后计算得出的。如果不存在偏差，这些直线将与图表右侧的学习曲线完全吻合。中间一行展示了两款游戏中DQN过度乐观情况尤为明显的对数值估计（以对数尺度表示）。最下面一行则显示了这种过度乐观对智能体在训练过程中评估时所取得分数的负面影响：一旦出现高估现象，分数便会下降。而使用Double DQN进行学习则要稳定得多。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251031165410.C0pcGUmQ.png&amp;amp;w=1352&amp;amp;h=1024&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. 竞争深度Q网络&lt;/h2&gt;
&lt;p&gt;相比于原本的DQN, 它唯一的差别就是改变了网络的架构. DQN输入的是状态, 输出的是每一个动作的Q值. 而竞争深度Q网络不直接输出Q值, 而是分成两条路径运算, 第一条路径会输出一个标量$V(s)$ , 第二条路径会输出一个向量$A(s,a)$, 把这两者加起来够成新的Q值$Q(s,a)$.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251031170924.BPR7Gk1p.png&amp;amp;w=1230&amp;amp;h=728&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
这样做有什么好处呢? 答案是我们不需要把所有的状态-动作对都采样, 可以不修改$A(s,a)$ 转而修改$V(s)$. 因为很多时候, 一个动作并不会太大影响即使在在这个状态的价值了. 我们这样修改, Q表的值也会被修改, 但是修改的话可以仅仅通过调整V值如下图:
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251031171524.BUdzW_2j.png&amp;amp;w=582&amp;amp;h=386&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;那么剩下的问题就是如何让网络倾向于修改V来解决问题. 最直观的方法就是, 我们给A加上约束, 让网络倾向于修改V来解决问题. 比如, 我们可以控制A的均值为0, 所以更新单个A值就不可行了, 网络就会更新在V值上.&lt;/p&gt;
&lt;p&gt;对于具体的实现, 我们将A和V相加之前, 先进行归一化让A列之和等于0.&lt;/p&gt;
&lt;h2&gt;3. 优先级经验回放 (Prioritized Experience Replay, PER)&lt;/h2&gt;
&lt;p&gt;我们原本在采样数据训练Q网络的过程中, 会均匀从回放缓冲区采样数据, 然而这样并不一定是好的, 因为一些数据非常重要. 所以我们就需要给不同的数据优先权 (priority). 做PER的时候, 因为改变了采样的过程, 更新参数的方法也要更改.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251031172513.DNs-Nl5I.png&amp;amp;w=1384&amp;amp;h=712&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. 多步更新 -- MC + TD&lt;/h2&gt;
&lt;p&gt;这个就不用解释了, 多步更新即可.&lt;/p&gt;
&lt;h2&gt;5. 噪声网络 (noisy net)&lt;/h2&gt;
&lt;p&gt;探索的过程也可以改进, $\epsilon$-贪心就是在动作的空间上加噪声. 噪声网路是给参数的空间加上噪声. 比如我们给网路上每一个参数加上一个高斯噪声, 就把原来的Q变成了$\widetilde{Q}$ , 称为&lt;strong&gt;噪声Q函数(noisy Q-function)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;OpenAI和DeepMind几乎在同一时间提出了几乎一模一样的噪声网络方法, 只是作用范围不同. 日后有机会在看看读不读吧.&lt;/p&gt;
&lt;h2&gt;6. 分布式Q函数&lt;/h2&gt;
&lt;p&gt;分布式Q函数是一个比较合适但难以实现的代码. 事情是这样的, 我们算出来的Q值是一个期望值. 我们把某一个状态采取某一个动作时, 得到的所有奖励在游戏结束时进行统计, 就会得到一个分布, 我们对这个分布计算平均值才是Q值, 算出来是累积奖励的期望. 也就是说, 累积奖励也是一个分布, 对它求期望, 再取平均值, 得到Q值.&lt;/p&gt;
&lt;p&gt;但是不同的分布可能会相同的均值, 我们用Q值的期望来代替这个那个奖励, 这样可能丢失一些信息, 无法对真实的奖励分布建模:
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251101000909.CxjWJLzS.png&amp;amp;w=598&amp;amp;h=202&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;分布式Q函数是对distribution建模. 具体的做法暂时不用去管.&lt;/p&gt;
&lt;h1&gt;四. 针对连续动作的深度Q网络&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;前面主要是在针对Q网络展开讨论其设计初衷, 但是我们仍然假设动作时离散的. 但是如果a是无限的, 该怎么利用Q网络? 以下有几种解决的方法&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 对动作采样&lt;/h2&gt;
&lt;p&gt;这个方案也是最原始最直观的, 我们尽量采样多个动作, 并选择一个最大的Q. 这不是一个精确的方案, 但是并不会太低效: 因为我们会在计算中使用GPU, 进行并行运算.&lt;/p&gt;
&lt;h2&gt;2. 梯度上升&lt;/h2&gt;
&lt;p&gt;我们找a的本质是解决一个优化问题, 最大化目标函数. 因此我们就可以采用梯度上升, 将a作为参数, 找一组a去最大化Q函数, 就用梯度上升去更新a的值, 直到最后收敛.&lt;/p&gt;
&lt;p&gt;既然是梯度上升, 就面临两个问题, 一个是局部最大值问题, 另一个就是每次决定采取动作的时候还是要训练一次网络, 计算量还是很大.&lt;/p&gt;
&lt;h2&gt;3. 设计网络架构&lt;/h2&gt;
&lt;p&gt;我们通过特别设计Q函数来解决arg max操作问题, 通过, 我们输入的状态s可以用向量或矩阵来表示它, Q函数则会输出向量$\mu(s)$ 、矩阵$\Sigma(s)$ 和标量$V(s)$.
$$
Q(\boldsymbol{s},\boldsymbol{a})=-(\boldsymbol{a}-\boldsymbol{\mu}(\boldsymbol{s}))^\mathrm{T}\boldsymbol{\Sigma}(\boldsymbol{s})(\boldsymbol{a}-\boldsymbol{\mu}(\boldsymbol{s}))+V(\boldsymbol{s})\tag{4.3.1}
$$
注意这里的a是连续的动作, 所以是一个向量. $\boldsymbol{a}$ 和$\boldsymbol{\mu}(\boldsymbol{s})$ 都是列向量, $(\boldsymbol{a}-\boldsymbol{\mu}(\boldsymbol{s}))^\mathrm{T}$ 是一个行向量, $\boldsymbol{\Sigma}(\boldsymbol{s})$ 是一个正定矩阵. 通过矩阵运算很显然Q值是一个标量.&lt;/p&gt;
&lt;p&gt;我们让$(\boldsymbol{a}-\boldsymbol{\mu}(\boldsymbol{s}))^\mathrm{T}\boldsymbol{\Sigma}(\boldsymbol{s})(\boldsymbol{a}-\boldsymbol{\mu}(\boldsymbol{s}))+V(\boldsymbol{s})$ 的值越小, 显然Q的值就越大. 很显然, 令 $\boldsymbol{a}$ 接近$\boldsymbol{\mu}(\boldsymbol{s})$ , 得到的Q值就会更大, 从而解决arg max操作.&lt;/p&gt;
&lt;p&gt;综上而言, 深度Q网络也可以用于连续的情况中, 只是有一定的局限: 函数不能随意设置.&lt;/p&gt;
&lt;p&gt;关于这个网络的具体细节这里暂时略过, 可能后面在实现时会进行补充说明.&lt;/p&gt;
&lt;h2&gt;4. 干脆不使用DQN吧&lt;/h2&gt;
&lt;p&gt;Q函数无论如何处理连续数字都很麻烦, 于是我们可以优化算法. 我们将基于策略的方法如PPO于基于价值的方法如DQN结合, 就可以得到Actor-Critic算法, 由于时策略导向, 并不在乎动作的连续性. 我们将在后续章节中继续介绍.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251101021003.Cr8h5sQQ.png&amp;amp;w=526&amp;amp;h=236&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>FastAPI 请求编码切换：表单、文件上传与 UploadFile</title><link>https://owen571.top/posts/study/fastapi/06-fastapi-%E8%A1%A8%E5%8D%95-%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E4%B8%8E%E8%AF%B7%E6%B1%82%E7%BC%96%E7%A0%81/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/06-fastapi-%E8%A1%A8%E5%8D%95-%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E4%B8%8E%E8%AF%B7%E6%B1%82%E7%BC%96%E7%A0%81/</guid><description>从 JSON 切到 multipart/form-data，把 Form、File、UploadFile、表单模型和多文件上传一并收进请求编码这一层。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;到这里最容易产生的误解是：好像“FastAPI 接请求”就等于“FastAPI 收 JSON”。其实不是。只要开始碰登录表单、图片上传、附件上传，请求编码就已经切到另一层了。&lt;/p&gt;
&lt;h2&gt;1. 为什么上传文件一定会牵涉到表单&lt;/h2&gt;
&lt;p&gt;JSON 和表单最大的区别不是语法，而是使用场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JSON：更适合结构化数据交换&lt;/li&gt;
&lt;li&gt;&lt;code&gt;multipart/form-data&lt;/code&gt;：适合文本字段 + 二进制文件一起传&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以一旦接口里要上传文件，几乎就等于在说：这次请求不会是普通 JSON，而会是表单编码。&lt;/p&gt;
&lt;h2&gt;2. 用 &lt;code&gt;Form&lt;/code&gt; 接收表单字段&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import FastAPI, Form

app = FastAPI()


@app.post(&quot;/login/&quot;)
async def login(
    username: Annotated[str, Form()],
    password: Annotated[str, Form()],
):
    return {&quot;username&quot;: username}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的重点不是“又多学一个函数”，而是明确告诉 FastAPI：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这两个参数不是从 JSON 里读&lt;/li&gt;
&lt;li&gt;而是从表单字段里读&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果项目里要收表单数据，需要先安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install python-multipart
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 表单也可以建模&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import FastAPI, Form
from pydantic import BaseModel

app = FastAPI()


class FormData(BaseModel):
    username: str
    password: str


@app.post(&quot;/login/&quot;)
async def login(data: Annotated[FormData, Form()]):
    return data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一点很值，因为它说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表单字段不是“只能散着收”&lt;/li&gt;
&lt;li&gt;也能继续走模型化这条路&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. 文件上传：&lt;code&gt;bytes&lt;/code&gt; 和 &lt;code&gt;UploadFile&lt;/code&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import FastAPI, File, UploadFile

app = FastAPI()


@app.post(&quot;/files/&quot;)
async def create_file(file: Annotated[bytes, File()]):
    return {&quot;file_size&quot;: len(file)}


@app.post(&quot;/uploadfile/&quot;)
async def create_upload_file(file: UploadFile):
    return {&quot;filename&quot;: file.filename}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两种写法都能收文件，但语义不一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bytes&lt;/code&gt;：FastAPI 直接把整个文件读进内存&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UploadFile&lt;/code&gt;：给你一个更适合处理文件流的大文件接口&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. 为什么 &lt;code&gt;UploadFile&lt;/code&gt; 更常用&lt;/h2&gt;
&lt;p&gt;你在本地笔记里把它写得很清楚，核心优势有这些：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文件先在内存里缓冲，超过阈值后会落盘&lt;/li&gt;
&lt;li&gt;更适合图片、视频、大文件&lt;/li&gt;
&lt;li&gt;能拿到元数据，比如 &lt;code&gt;filename&lt;/code&gt;、&lt;code&gt;content_type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;提供异步文件方法&lt;/li&gt;
&lt;li&gt;底层暴露的是真正的 file-like 对象&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以简单记法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;小文件、只想马上拿内容：&lt;code&gt;bytes&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;更真实的上传场景：&lt;code&gt;UploadFile&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. &lt;code&gt;UploadFile&lt;/code&gt; 常用属性和方法&lt;/h2&gt;
&lt;p&gt;最常用的属性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;filename&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;content_type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;file&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最常用的方法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;await file.read()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await file.write(data)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await file.seek(0)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await file.close()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;尤其是 &lt;code&gt;seek(0)&lt;/code&gt;，在“已经读过一次，还想再从头处理”的场景里很常见。&lt;/p&gt;
&lt;h2&gt;7. 同时接表单和文件&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import FastAPI, File, Form, UploadFile

app = FastAPI()


@app.post(&quot;/files/&quot;)
async def create_file(
    file: Annotated[bytes, File()],
    fileb: Annotated[UploadFile, File()],
    token: Annotated[str, Form()],
):
    return {
        &quot;file_size&quot;: len(file),
        &quot;token&quot;: token,
        &quot;fileb_content_type&quot;: fileb.content_type,
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是表单编码最常见的真实场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文本字段&lt;/li&gt;
&lt;li&gt;一个或多个文件&lt;/li&gt;
&lt;li&gt;一次请求一起提交&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;8. 多文件上传和可选文件&lt;/h2&gt;
&lt;p&gt;文件参数也能继续做扩展：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可选文件：给默认值 &lt;code&gt;None&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;多文件上传：声明成 &lt;code&gt;list[UploadFile]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;即便是 &lt;code&gt;UploadFile&lt;/code&gt;，也能继续在 &lt;code&gt;File()&lt;/code&gt; 里补元信息&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以它的使用方式和前面学过的 &lt;code&gt;Body / Query / Form&lt;/code&gt; 很一致，只不过这次载体换成了文件。&lt;/p&gt;
&lt;h2&gt;9. 从请求流角度看这一层&lt;/h2&gt;
&lt;p&gt;到这里，其实不是又学了三个新 API，而是把“请求编码”这个层补完整了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;URL 参数：路径和查询&lt;/li&gt;
&lt;li&gt;JSON 请求体：Pydantic 模型&lt;/li&gt;
&lt;li&gt;表单和文件：&lt;code&gt;Form / File / UploadFile&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样你后面再看认证表单、头像上传、附件接口，就不会觉得这些接口是完全不同的一套东西。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 前置：OpenAI API 调用基线</title><link>https://owen571.top/posts/study/langchain/01-openai-api-%E8%B0%83%E7%94%A8%E5%9F%BA%E7%BA%BF/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/01-openai-api-%E8%B0%83%E7%94%A8%E5%9F%BA%E7%BA%BF/</guid><description>在正式进入 LangChain 之前，先建立最小调用心智：同步、异步、流式和常见参数到底是什么。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇被我放在 LangChain 学习路径的最前面。它严格来说不是 LangChain 本体，而是为了先弄明白“模型调用本身长什么样”，后面看 Models、Messages、Streaming 时会顺很多。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍与安装&lt;/h2&gt;
&lt;p&gt;官网的介绍是：OpenAI API 可应用于理解或生成自然语言、代码或图像的几乎所有任务。我们提供一系列不同功率级别的模型，适用于不同的任务，并具有微调自定义模型的能力。这些模型可以用于从内容生成到语义搜索和分类的一切。&lt;/p&gt;
&lt;p&gt;我们要调用了解OpenAI包的用法，可以前往&lt;a href=&quot;https://github.com/openai/openai-python&quot;&gt;OpenAI Python API library&lt;/a&gt;查看；如果想快速用了解怎么用这个包来开发，可以看&lt;a href=&quot;https://developers.openai.com/api/docs/quickstart&quot;&gt;OpenAI Developers的接口文档&lt;/a&gt;。笔者整理的时候，这个包在pypi上的stable版本已经v2.29.0，一些教程还在用旧版的接口。&lt;/p&gt;
&lt;p&gt;首先，最基本的当然是从PyPI安装&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install openai
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装完成用pip show openai可以看到&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Name: openai
Version: 2.29.0
Summary: The official Python library for the openai API
Home-page: https://github.com/openai/openai-python
Author: 
Author-email: OpenAI &amp;lt;support@openai.com&amp;gt;
License: Apache-2.0
Location: /opt/homebrew/anaconda3/envs/agent/lib/python3.13/site-packages
Requires: anyio, distro, httpx, jiter, pydantic, sniffio, tqdm, typing-extensions
Required-by: 
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 快速使用&lt;/h2&gt;
&lt;h3&gt;(1) 主流新接口 - responses.create(...)&lt;/h3&gt;
&lt;p&gt;github页提供了一个示例。由于我们没有OpenAI额度😭，我们换中转API。&lt;/p&gt;
&lt;p&gt;一般情况下，我们会用python-dotenv的方法将API秘钥添加到.env中，然后载入，防止直接写进源码。下面写法也可以不用find_dotenv，直接一句load_dotenv()，就会去默认环境找。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from dotenv import load_dotenv, find_dotenv
from openai import OpenAI

_ = load_dotenv(find_dotenv())

client = OpenAI(
    api_key=os.environ[&quot;QIHANG_API&quot;],
    base_url=os.environ[&quot;QIHANG_BASE_URL&quot;]
)

response = client.responses.create(
    model = &quot;gpt-4o-mini&quot;,
    instructions= &quot;你是猪&quot;,
    input = &quot;叫一声&quot;
)

print(response.output_text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/langchain/image.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(2) 传统聊天信息 - chat.completions&lt;/h3&gt;
&lt;p&gt;这是偏“传统聊天消息”的接口风格。相比而言，新版的instructions + input更像直接回答，而messages更像多轮聊天形式。&lt;/p&gt;
&lt;p&gt;另外需要注意的是，这里的role必须是标准角色，比如system、user、assistant。&lt;/p&gt;
&lt;p&gt;对比两者，还有接口返回的结构不同，可以观察一下。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from dotenv import load_dotenv, find_dotenv
from openai import OpenAI

_ = load_dotenv(find_dotenv())

client = OpenAI(
    api_key=os.environ[&quot;QIHANG_API&quot;],
    base_url=os.environ[&quot;QIHANG_BASE_URL&quot;]
)

completion = client.chat.completions.create(
    model=&quot;gpt-4o-mini&quot;,
    messages=[
        {
            &quot;role&quot;: &quot;system&quot;, 
            &quot;content&quot;: &quot;你要像一只猪一样说话&quot;
        },
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: &quot;你最喜欢什么事情啊？&quot;,
        },
    ],
)

print(completion.choices[0].message.content)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/langchain/image-1.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(3) 图像&lt;/h3&gt;
&lt;p&gt;可以在input里面用content加入type键。默认&lt;code&gt;input_text&lt;/code&gt;换成&lt;code&gt;input_image&lt;/code&gt;即可图像即可，有两种形式，一种是用在线图像的URL，一般用&lt;code&gt;{&quot;type&quot;: &quot;input_image&quot;, &quot;image_url&quot;: f&quot;{img_url}&quot;}&lt;/code&gt;，一种是base64，base64包的用法在这里略掉，可以在&lt;a href=&quot;https://docs.python.org/zh-cn/3/library/base64.html&quot;&gt;Base64包用法&lt;/a&gt;里面查看。&lt;/p&gt;
&lt;h3&gt;(4) 异步使用&lt;/h3&gt;
&lt;p&gt;与正常使用几乎没区别，只是换成了AsyncOpenAI，举例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import asyncio
from openai import AsyncOpenAI
from dotenv import load_dotenv

load_dotenv()

client = AsyncOpenAI(
    api_key=os.environ.get(&quot;QIHANG_API&quot;),
    base_url=os.environ.get(&quot;QIHANG_BASE_URL&quot;)
)


async def main() -&amp;gt; None:
    response = await client.responses.create(
        model=&quot;gpt-4o-mini&quot;, input=&quot;Explain disestablishmentarianism to a smart five year old.说中文&quot;
    )
    print(response.output_text)


asyncio.run(main())
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(5) aiohttp&lt;/h3&gt;
&lt;p&gt;默认情况下，异步客户端使用 HTTP 请求。然而，为了提高并发性能，也可以使用 aiohttp 作为 HTTP 后端。不过aiohttp暂时还没看，skip一下。&lt;/p&gt;
&lt;h3&gt;(6) 流式回答&lt;/h3&gt;
&lt;p&gt;流式回答可以让模型不要等整段生成完再一次性返回，而是边生成边把事件流发回来。官方文档描述为server-sent events，SDK中会拿到一个可迭代对象，所以能一直打印，直到收到完成事件为止。&lt;/p&gt;
&lt;p&gt;直接print会打印整个对象的一大堆信息，我们也可以看一下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ResponseTextDeltaEvent(content_index=0, delta=&apos;善&apos;, item_id=&apos;msg_01cd90f5c2f813180069c3fef6a7e08190b9a175ce86233099&apos;, logprobs=[], output_index=0, sequence_number=492, type=&apos;response.output_text.delta&apos;, obfuscation=&apos;JRiZxCSRiPjcp8M&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果想呈现目前常见的打字机输出，可以只打印每个事件的delta字段，然后把flush设置为True（即将缓存区的数据立刻写入文件同时清空缓冲区）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import asyncio
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

client = OpenAI(
    api_key=os.environ.get(&quot;QIHANG_API&quot;),
    base_url=os.environ.get(&quot;QIHANG_BASE_URL&quot;)
)


stream = client.responses.create(
    model= &quot;gpt-4o-mini&quot;,
    input = &quot;写一个关于猪的鬼故事&quot;,
    stream = True
)

# stream会得到可迭代的一堆event
for event in stream:
    if event.type == &quot;response.output_text.delta&quot;:
        print(event.delta,end=&quot;&quot;,flush=True)
print()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;暂时经常用到的应该就是这些，后面可以边学边看&lt;/p&gt;
&lt;h2&gt;3. 参数&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/openai/openai-python/blob/main/api.md&quot;&gt;API字典&lt;/a&gt;在这里，可以随用随看，里面包含一大堆参数。&lt;/p&gt;
</content:encoded></item><item><title>LangGraph 核心能力 07：Subgraphs 子图与复用</title><link>https://owen571.top/posts/study/langgraph/08-langgraph-%E5%AD%90%E5%9B%BE-subgraphs/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/08-langgraph-%E5%AD%90%E5%9B%BE-subgraphs/</guid><description>子图如何作为节点复用、如何共享 state、如何流式查看子图执行与持久化模式选择。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;LangGraph能力 - Subgraphs (子图)&lt;/h1&gt;
&lt;p&gt;子图是一种在另一张图中作为图节点使用的节点。适用于以下场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;构建多智能体系统&lt;/li&gt;
&lt;li&gt;在多张图中复用一组节点&lt;/li&gt;
&lt;li&gt;分布式开发：当需要不同团队独立负责图的不同部分时，可将各部分定义为子图。只要遵循子图接口（输入与输出模式），父图即可在无需了解子图任何细节的情况下完成构建&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;添加子图时，需要定义父图与子图之间的通信方式：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;模式&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;th&gt;状态 schema 特点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;在节点内部调用子图&lt;/td&gt;
&lt;td&gt;父图和子图的状态 schema 不同，二者没有共享键；或者你需要在父图与子图之间做状态转换&lt;/td&gt;
&lt;td&gt;需要自己写一个包装节点，把父图 state 映射成子图输入，再把子图输出映射回父图 state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;将子图直接作为节点加入&lt;/td&gt;
&lt;td&gt;父图和子图共享部分状态键；子图可以直接读写父图的同一批 state channel&lt;/td&gt;
&lt;td&gt;直接把编译好的子图传给 &lt;code&gt;add_node&lt;/code&gt;，不需要额外包装函数&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;1. 节点内部调用：&lt;/h2&gt;
&lt;p&gt;当父图与子图拥有不同的状态结构（无共享键）时，需在节点函数内部调用子图。这种做法常见于多智能体系统中需要为每个智能体保留独立消息历史的场景。&lt;/p&gt;
&lt;p&gt;节点函数会在调用子图前将父图状态转换为子图状态，并在返回前将结果转换回父图状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import TypedDict
from langgraph.graph.state import StateGraph, START

class SubgraphState(TypedDict):
    bar: str

# Subgraph

def subgraph_node_1(state: SubgraphState):
    return {&quot;bar&quot;: &quot;hi! &quot; + state[&quot;bar&quot;]}

def subgraph_node_2(state: SubgraphState):
    return {&quot;bar&quot;: state[&quot;bar&quot;] + &quot;!&quot;}

subgraph_builder = StateGraph(SubgraphState)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.add_node(subgraph_node_2)
subgraph_builder.add_edge(START, &quot;subgraph_node_1&quot;)
subgraph_builder.add_edge(&quot;subgraph_node_1&quot;, &quot;subgraph_node_2&quot;)
subgraph = subgraph_builder.compile()

# Parent graph

class State(TypedDict):
    foo: str

def call_subgraph(state: State):
    # Transform the state to the subgraph state
    subgraph_output = subgraph.invoke({&quot;bar&quot;: state[&quot;foo&quot;]})
    # Transform response back to the parent state
    return {&quot;foo&quot;: subgraph_output[&quot;bar&quot;]}

builder = StateGraph(State)
builder.add_node(&quot;node_1&quot;, call_subgraph)
builder.add_edge(START, &quot;node_1&quot;)
graph = builder.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为父图和子图的state不一样，上例用了一个&lt;code&gt;call_subgraph&lt;/code&gt;包装，来把父图的状态转化为子图的输入，再把子图的输出转回父图的状态。&lt;/p&gt;
&lt;h2&gt;2. 子图作为node加入&lt;/h2&gt;
&lt;p&gt;当父图与子图共享状态键（State）时，可将编译后的子图直接传入add_node。无需包装函数 —— 子图会自动读写父图的状态通道。例如，在多智能体系统中，智能体通常通过共享的messages键进行通信。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-10.brXWeHea.png&amp;amp;w=1177&amp;amp;h=818&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;如果子图与父图共享状态键，可按照以下步骤将其添加到你的图中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;定义子图工作流（下方示例中的subgraph_builder）并对其进行编译&lt;/li&gt;
&lt;li&gt;在定义父图工作流时，将编译后的子图传入add_node方法&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import TypedDict
from langgraph.graph.state import StateGraph, START

class State(TypedDict):
    foo: str

# Subgraph

def subgraph_node_1(state: State):
    return {&quot;foo&quot;: &quot;hi! &quot; + state[&quot;foo&quot;]}

def subgraph_node_2(state: State):
    return {&quot;foo&quot;: state[&quot;foo&quot;] + &quot;!&quot;}

subgraph_builder = StateGraph(State)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.add_node(subgraph_node_2)
subgraph_builder.add_edge(START, &quot;subgraph_node_1&quot;)
subgraph_builder.add_edge(&quot;subgraph_node_1&quot;, &quot;subgraph_node_2&quot;)
subgraph = subgraph_builder.compile()

# Parent graph

builder = StateGraph(State)
builder.add_node(&quot;node_1&quot;, subgraph)
builder.add_edge(START, &quot;node_1&quot;)
graph = builder.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要有共享的 state key，就可以直接作为 node 加入，同时子图还可以有自己私有的 key，也就是说，子图结构可以比父图更复杂。&lt;/p&gt;
&lt;h2&gt;3. 流式看到子图内部执行&lt;/h2&gt;
&lt;p&gt;只需要调整一个参数就可以，然后，我们就可以通过chunk[&quot;ns&quot;] 看这个事件来自哪里，ns == ()表示是主图，如果来自某个子图可能是&lt;code&gt;ns == (&quot;node_2:&amp;lt;task_id&amp;gt;&quot;,)&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph.stream(..., subgraphs=True, version=&quot;v2&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 子图的持久化模式&lt;/h2&gt;
&lt;p&gt;子图在 compile() 时，checkpointer 有 3 种模式：&lt;/p&gt;
&lt;p&gt;checkpointer=None&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;默认&lt;/li&gt;
&lt;li&gt;每次调用子图都从头开始&lt;/li&gt;
&lt;li&gt;但单次调用内部仍继承父图 checkpointer，支持 interrupt / durable execution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;checkpointer=True&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;子图按 thread 持续积累状态&lt;/li&gt;
&lt;li&gt;下次调用同一个子图时，会接着上次记忆继续&lt;/li&gt;
&lt;li&gt;适合“子 agent 自己也要有多轮记忆”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;checkpointer=False&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;完全无 checkpoint&lt;/li&gt;
&lt;li&gt;像普通函数调用&lt;/li&gt;
&lt;li&gt;不支持 interrupt / durable execution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于有多个“有记忆的子图”命名时，我们要给稳定的namespace进行空间隔离。&lt;/p&gt;
&lt;h2&gt;5. 查询子图状态&lt;/h2&gt;
&lt;p&gt;我们通过&lt;code&gt;graph.get_state(config, subgraphs=True)&lt;/code&gt;来获取快照，然后可以用&lt;code&gt;.tasks[0].state&lt;/code&gt;来看子图的内部状态。&lt;/p&gt;
&lt;p&gt;下面给一个最小的可运行子图示例，包含了子图持久化、namespace隔离、查询子图状态、查看子图流输出等，包含详细注释：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import TypedDict, NotRequired
from langgraph.graph import StateGraph, START
from langgraph.checkpoint.memory import InMemorySaver


# -----------------------------
# 1) 父图 state
# -----------------------------
# 父图只关心共享字段：
# - request: 输入任务
# - result: 子图处理后的结果
class ParentState(TypedDict):
    request: str
    result: NotRequired[str]


# -----------------------------
# 2) 子图 state
# -----------------------------
# 子图既可以读写父图共享键，也可以维护自己的私有键：
# - request: 与父图共享
# - result: 与父图共享
# - visits: 子图私有，用来证明“子图会跨调用记忆”
# - agent_name: 子图私有
class SubgraphState(TypedDict):
    request: str
    result: NotRequired[str]
    visits: NotRequired[int]
    agent_name: NotRequired[str]


def build_agent_subgraph(label: str):
    &quot;&quot;&quot;构造一个最小子图。
    
    这个子图只有一个节点：
    - 每次被调用时，把 visits + 1
    - 写入自己的私有状态 agent_name / visits
    - 同时更新与父图共享的 result
    &quot;&quot;&quot;

    def remember(state: SubgraphState):
        # 这里的 visits 是子图自己的内部状态。
        # 如果子图开启了 per-thread 持久化，那么同一 thread 下多次调用会持续累加。
        visits = state.get(&quot;visits&quot;, 0) + 1

        return {
            &quot;visits&quot;: visits,
            &quot;agent_name&quot;: label,
            &quot;result&quot;: f&quot;{label} handled &apos;{state[&apos;request&apos;]}&apos; (visit {visits})&quot;,
        }

    builder = StateGraph(SubgraphState)
    builder.add_node(&quot;remember&quot;, remember)
    builder.add_edge(START, &quot;remember&quot;)

    # 关键点 1：
    # checkpointer=True 表示这个子图拥有“per-thread 持久化”。
    # 同一个 thread_id 下，下次再调用这个子图时，它会记得上次的内部状态。
    return builder.compile(checkpointer=True)


# -----------------------------
# 3) 构造两个子图
# -----------------------------
research_agent = build_agent_subgraph(&quot;research&quot;)
writer_agent = build_agent_subgraph(&quot;writer&quot;)


# -----------------------------
# 4) 父图把子图直接作为节点加入
# -----------------------------
parent_builder = StateGraph(ParentState)

# 关键点 2：
# 这里直接把“编译好的子图”传给 add_node。
# 因为父图和子图共享 request/result 这两个键，所以不需要额外包装函数。
parent_builder.add_node(&quot;research_agent&quot;, research_agent)
parent_builder.add_node(&quot;writer_agent&quot;, writer_agent)

parent_builder.add_edge(START, &quot;research_agent&quot;)
parent_builder.add_edge(&quot;research_agent&quot;, &quot;writer_agent&quot;)

# 父图本身也需要一个 checkpointer。
# 没有父图 checkpointer，子图的持久化/检查/中断能力都没法正常工作。
checkpointer = InMemorySaver()
graph = parent_builder.compile(checkpointer=checkpointer)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;demo-thread&quot;}}


# -----------------------------
# 5) 第一次调用
# -----------------------------
print(&quot;=== Run 1 ===&quot;)
result1 = graph.invoke({&quot;request&quot;: &quot;first task&quot;}, config)
print(result1)
# 预期：
# {&apos;request&apos;: &apos;first task&apos;, &apos;result&apos;: &quot;writer handled &apos;first task&apos; (visit 1)&quot;}


# -----------------------------
# 6) 第二次调用（同一个 thread）
# -----------------------------
print(&quot;\n=== Run 2 ===&quot;)
result2 = graph.invoke({&quot;request&quot;: &quot;second task&quot;}, config)
print(result2)
# 预期：
# research 子图和 writer 子图都会各自把 visits 从 1 累加到 2
# 最终 result 会显示 writer handled ... (visit 2)


# -----------------------------
# 7) 看流式输出，观察 namespace 隔离
# -----------------------------
print(&quot;\n=== Stream Run 3 ===&quot;)
for chunk in graph.stream(
    {&quot;request&quot;: &quot;third task&quot;},
    config,
    stream_mode=&quot;updates&quot;,
    subgraphs=True,
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;updates&quot;:
        print(&quot;ns =&quot;, chunk[&quot;ns&quot;], &quot;data =&quot;, chunk[&quot;data&quot;])

# 你会看到类似：
# ns = (&apos;research_agent&apos;,) ...
# ns = () ...
# ns = (&apos;writer_agent&apos;,) ...
# ns = () ...
#
# 这说明：
# - research_agent 子图的内部更新进入了它自己的 namespace
# - writer_agent 子图的内部更新进入了它自己的 namespace
# - 这就是“namespace 隔离”
#
# 由于这两个子图是“作为不同节点加入父图”的，
# LangGraph 会自动按节点名给它们稳定分配 namespace。


# -----------------------------
# 8) 查询子图自己的最新状态
# -----------------------------
# 关键点 3：
# 图执行完以后，想稳定读取某个子图的状态，
# 最直接的方法是显式指定 checkpoint_ns。

research_state = graph.get_state(
    {
        &quot;configurable&quot;: {
            &quot;thread_id&quot;: &quot;demo-thread&quot;,
            &quot;checkpoint_ns&quot;: &quot;research_agent&quot;,
        }
    }
)

writer_state = graph.get_state(
    {
        &quot;configurable&quot;: {
            &quot;thread_id&quot;: &quot;demo-thread&quot;,
            &quot;checkpoint_ns&quot;: &quot;writer_agent&quot;,
        }
    }
)

print(&quot;\n=== Latest research subgraph state ===&quot;)
print(research_state.values)
# 预期类似：
# {
#   &apos;request&apos;: &apos;third task&apos;,
#   &apos;result&apos;: &quot;research handled &apos;third task&apos; (visit 3)&quot;,
#   &apos;visits&apos;: 3,
#   &apos;agent_name&apos;: &apos;research&apos;
# }

print(&quot;\n=== Latest writer subgraph state ===&quot;)
print(writer_state.values)
# 预期类似：
# {
#   &apos;request&apos;: &apos;third task&apos;,
#   &apos;result&apos;: &quot;writer handled &apos;third task&apos; (visit 3)&quot;,
#   &apos;visits&apos;: 3,
#   &apos;agent_name&apos;: &apos;writer&apos;
# }


# -----------------------------
# 9) 可选：查看底层 checkpoint，观察 namespace
# -----------------------------
print(&quot;\n=== Raw checkpoint namespaces ===&quot;)
for ckpt in checkpointer.list({&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;demo-thread&quot;}}):
    cfg = ckpt.config[&quot;configurable&quot;]
    print(&quot;checkpoint_ns =&quot;, cfg[&quot;checkpoint_ns&quot;], &quot;checkpoint_id =&quot;, cfg[&quot;checkpoint_id&quot;])
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>PyTorch 工具箱：Module、functional、optim 与初始化</title><link>https://owen571.top/posts/study/pytorch/04-pytorch-module-functional-optim-%E5%B7%A5%E5%85%B7%E7%AE%B1/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/04-pytorch-module-functional-optim-%E5%B7%A5%E5%85%B7%E7%AE%B1/</guid><description>把容易散落在不同笔记里的 PyTorch 常用工具收成一篇：nn.Module、nn.functional、optim、初始化与常见工程辅助接口。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇主要整理自 &lt;code&gt;pytorch_learning/pytorch_6.py&lt;/code&gt; 到 &lt;code&gt;pytorch_learning/pytorch_10.py&lt;/code&gt;，以及 &lt;code&gt;liuer_pytorch/9.ipynb&lt;/code&gt; 里那部分对 PyTorch 包结构的速查总结。这些内容单看都不难，但最容易散，放到一起反而更适合复习。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. &lt;code&gt;nn.Module&lt;/code&gt; 是网络的基本壳&lt;/h2&gt;
&lt;p&gt;PyTorch 里最核心的对象，就是继承 &lt;code&gt;nn.Module&lt;/code&gt; 的网络。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import torch as t
from torch import nn


class Perceptron(nn.Module):
    def __init__(self, in_features, hidden_features, out_features):
        super().__init__()
        self.layer1 = nn.Linear(in_features, hidden_features)
        self.layer2 = nn.Linear(hidden_features, out_features)

    def forward(self, x):
        x = self.layer1(x)
        x = t.sigmoid(x)
        return self.layer2(x)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的经验可以记成一句：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只要一个结构里有可学习参数，它大概率就应该放进 &lt;code&gt;nn.Module&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数会被自动注册&lt;/li&gt;
&lt;li&gt;&lt;code&gt;model.parameters()&lt;/code&gt; 才能收集到它们&lt;/li&gt;
&lt;li&gt;优化器才能更新它们&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. &lt;code&gt;nn&lt;/code&gt; 和 &lt;code&gt;nn.functional&lt;/code&gt; 的区别&lt;/h2&gt;
&lt;p&gt;我之前很容易把这两个混着用。现在更清楚的理解是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nn.*&lt;/code&gt;：偏对象化，适合有参数或有状态的层&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nn.functional.*&lt;/code&gt;：偏无状态纯函数，适合直接在 &lt;code&gt;forward()&lt;/code&gt; 里调用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nn.Linear&lt;/code&gt;、&lt;code&gt;nn.Conv2d&lt;/code&gt; 用 &lt;code&gt;nn&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;F.relu&lt;/code&gt;、&lt;code&gt;F.max_pool2d&lt;/code&gt; 这种更适合用 &lt;code&gt;functional&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;pytorch_9.py&lt;/code&gt; 里也有个很直观的小例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import torch as t
from torch import nn

inp = t.randn(2, 3)
model = nn.Linear(3, 4)
output1 = model(inp)
output2 = nn.functional.linear(inp, model.weight, model.bias)
print(output1 == output2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本质是一样的，只是组织代码的方式不同。&lt;/p&gt;
&lt;h2&gt;3. 优化器：不是只有 &lt;code&gt;SGD&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;在最开始的练习里，我几乎总是用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但到了 &lt;code&gt;pytorch_8.py&lt;/code&gt;，有两个很实用的工程技巧：&lt;/p&gt;
&lt;h3&gt;技巧一：不同层用不同学习率&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;optimizer = torch.optim.SGD(
    [
        {&quot;params&quot;: net.features.parameters()},
        {&quot;params&quot;: net.classifier.parameters(), &quot;lr&quot;: 1e-2},
    ],
    lr=1e-5,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个在迁移学习和微调时非常常见。&lt;/p&gt;
&lt;h3&gt;技巧二：动态调整学习率&lt;/h3&gt;
&lt;p&gt;虽然这里的代码只是做演示，但背后的工程直觉很重要：&lt;br /&gt;
学习率不是“一次写死到训练结束”，而是常常需要按阶段调。&lt;/p&gt;
&lt;h2&gt;4. 参数初始化&lt;/h2&gt;
&lt;p&gt;大多数时候，&lt;code&gt;nn.Module&lt;/code&gt; 已经带了合理默认初始化。&lt;br /&gt;
但 &lt;code&gt;pytorch_9.py&lt;/code&gt; 也提醒我，初始化并不是完全不用管。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from torch.nn import init

init.xavier_normal_(model.weight)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Xavier 初始化背后的目标，是让信号在网络中传播得更稳定，不容易一开始就炸掉或塌掉。&lt;/p&gt;
&lt;h2&gt;5. 其他容易散的小工具&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pytorch_10.py&lt;/code&gt; 虽然比较像提纲，但它点出了真正做项目时经常绕不过去的方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自定义 &lt;code&gt;Dataset&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;torchvision&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;可视化工具&lt;/li&gt;
&lt;li&gt;GPU 加速&lt;/li&gt;
&lt;li&gt;模型保存与加载&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些内容暂时还没有被我完全展开成独立文章，但它们其实构成了 PyTorch 从“写模型”到“做工程”的入口。&lt;/p&gt;
&lt;h2&gt;6. 把 PyTorch 的包结构记成一个简单地图&lt;/h2&gt;
&lt;p&gt;我后来比较喜欢的记法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;torch.*&lt;/code&gt;：张量和基础运算&lt;/li&gt;
&lt;li&gt;&lt;code&gt;torch.nn.*&lt;/code&gt;：网络层、损失函数、模块&lt;/li&gt;
&lt;li&gt;&lt;code&gt;torch.nn.functional.*&lt;/code&gt;：无状态操作&lt;/li&gt;
&lt;li&gt;&lt;code&gt;torch.optim.*&lt;/code&gt;：参数更新&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样至少不会在代码里每次都把几个包的职责混掉。&lt;/p&gt;
&lt;h2&gt;7. 这一阶段该记住什么&lt;/h2&gt;
&lt;p&gt;如果把这篇压缩成最少几句话，我会记：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;nn.Module&lt;/code&gt; 是网络的组织方式，不只是一个语法壳。&lt;/li&gt;
&lt;li&gt;有参数的层优先用 &lt;code&gt;nn.*&lt;/code&gt;，无状态操作常用 &lt;code&gt;F.*&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;优化器不是黑盒配置项，而是训练策略的一部分。&lt;/li&gt;
&lt;li&gt;初始化、学习率、数据集接口这些“边角 API”，其实很影响真实训练体验。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这篇看起来像杂项，但真正写 PyTorch 项目时，它往往是最常用的一层。&lt;/p&gt;
</content:encoded></item><item><title>RAG 数据加载：文档解析与预处理入口</title><link>https://owen571.top/posts/study/rag/02-rag-%E6%95%B0%E6%8D%AE%E5%8A%A0%E8%BD%BD%E4%B8%8E%E6%96%87%E6%A1%A3%E9%A2%84%E5%A4%84%E7%90%86/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/02-rag-%E6%95%B0%E6%8D%AE%E5%8A%A0%E8%BD%BD%E4%B8%8E%E6%96%87%E6%A1%A3%E9%A2%84%E5%A4%84%E7%90%86/</guid><description>从文档加载器开始，理解非结构化数据如何被抽取成可切分、可嵌入、可检索的标准化语料。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;RAG 的第一步不是“问模型”，而是“把外部知识变成可处理的数据”。这一篇主要整理文档加载器和预处理环节，为后面的分块与索引打底。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 数据加载&lt;/h1&gt;
&lt;h2&gt;一、文档加载器&lt;/h2&gt;
&lt;h3&gt;1. 主要功能&lt;/h3&gt;
&lt;p&gt;RAG 系统中，数据加载是整个流水线的第一步，也是不可或缺的一步。文档加载器负责将各种格式的非结构化文档（如PDF、Word、Markdown、HTML等）转换为程序可以处理的结构化数据。数据加载的质量会直接影响后续的索引构建、检索效果和最终的生成质量。&lt;/p&gt;
&lt;p&gt;文档加载器在 RAG 的数据管道中一般需要完成三个核心任务，一是解析不同格式的原始文档，将 PDF、Word、Markdown 等内容提取为可处理的纯文本，二是在解析过程中同时抽取文档来源、页码、作者等关键信息作为元数据，三是把文本和元数据整理成统一的数据结构，方便后续进行切分、向量化和入库，其整体流程与传统数据工程中的抽取、转换、加载相似，目标都是把杂乱的原始文档清洗并对齐为适合检索和建模的标准化语料。&lt;/p&gt;
&lt;h3&gt;2. 主流RAG文档加载器&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;工具&lt;/th&gt;
&lt;th&gt;功能&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;th&gt;特点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyMuPDF4LLM&lt;/td&gt;
&lt;td&gt;PDF → Markdown 转换，OCR + 表格识别&lt;/td&gt;
&lt;td&gt;科研文献、技术手册&lt;/td&gt;
&lt;td&gt;开源免费，GPU 加速&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TextLoader&lt;/td&gt;
&lt;td&gt;基础文本文件加载&lt;/td&gt;
&lt;td&gt;纯文本处理&lt;/td&gt;
&lt;td&gt;轻量高效&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DirectoryLoader&lt;/td&gt;
&lt;td&gt;批量目录文件处理&lt;/td&gt;
&lt;td&gt;混合格式文档库&lt;/td&gt;
&lt;td&gt;支持多格式扩展&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unstructured&lt;/td&gt;
&lt;td&gt;多格式文档解析&lt;/td&gt;
&lt;td&gt;PDF、Word、HTML 等&lt;/td&gt;
&lt;td&gt;统一接口，智能解析&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FireCrawlLoader&lt;/td&gt;
&lt;td&gt;网页内容抓取&lt;/td&gt;
&lt;td&gt;在线文档、新闻&lt;/td&gt;
&lt;td&gt;实时内容获取&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LlamaParse&lt;/td&gt;
&lt;td&gt;深度 PDF 结构解析&lt;/td&gt;
&lt;td&gt;法律合同、学术论文&lt;/td&gt;
&lt;td&gt;解析精度高，商业 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docling&lt;/td&gt;
&lt;td&gt;模块化企业级解析&lt;/td&gt;
&lt;td&gt;企业合同、报告&lt;/td&gt;
&lt;td&gt;IBM 生态兼容&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marker&lt;/td&gt;
&lt;td&gt;PDF → Markdown，GPU 加速&lt;/td&gt;
&lt;td&gt;科研文献、书籍&lt;/td&gt;
&lt;td&gt;专注 PDF 转换&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MinerU&lt;/td&gt;
&lt;td&gt;多模态集成解析&lt;/td&gt;
&lt;td&gt;学术文献、财务报表&lt;/td&gt;
&lt;td&gt;集成 LayoutLMv3 + YOLOv8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;二、Unstructured文档处理库&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.unstructured.io/open-source/introduction/overview&quot;&gt;Unstructured&lt;/a&gt;是一个专业的文档处理库，专门设计用于RAG和AI微调场景的非结构化数据预处理。提供了统一的接口来处理多种文档格式，是目前应用较广泛的文档加载解决方案之一。Unstructured 在格式支持和内容解析方面具有明显优势，它一方面支持 PDF、Word、Excel、HTML、Markdown 等多种文档格式，并通过统一的 API 接口避免为不同格式分别编写代码，另一方面可以自动识别标题、段落、表格、列表等文档结构，同时保留相应的元数据信息。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;元素类型&lt;/th&gt;
&lt;th&gt;描述&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Title&lt;/td&gt;
&lt;td&gt;文档标题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NarrativeText&lt;/td&gt;
&lt;td&gt;由多个完整句子组成的正文文本，不包括标题、页眉、页脚和说明文字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ListItem&lt;/td&gt;
&lt;td&gt;列表项，属于列表的正文文本元素&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table&lt;/td&gt;
&lt;td&gt;表格&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image&lt;/td&gt;
&lt;td&gt;图像元数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formula&lt;/td&gt;
&lt;td&gt;公式&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Address&lt;/td&gt;
&lt;td&gt;物理地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EmailAddress&lt;/td&gt;
&lt;td&gt;邮箱地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FigureCaption&lt;/td&gt;
&lt;td&gt;图片标题 / 说明文字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header&lt;/td&gt;
&lt;td&gt;文档页眉&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Footer&lt;/td&gt;
&lt;td&gt;文档页脚&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeSnippet&lt;/td&gt;
&lt;td&gt;代码片段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PageBreak&lt;/td&gt;
&lt;td&gt;页面分隔符&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PageNumber&lt;/td&gt;
&lt;td&gt;页码&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UncategorizedText&lt;/td&gt;
&lt;td&gt;未分类的自由文本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompositeElement&lt;/td&gt;
&lt;td&gt;分块处理时产生的复合元素*&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;三、从LangChain封装到原始Unstructured&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from unstructured.partition.auto import partition

# PDF文件路径
pdf_path = &quot;../../data/C2/pdf/rag.pdf&quot;

# 使用Unstructured加载并解析PDF文档
elements = partition(
    filename=pdf_path,
    content_type=&quot;application/pdf&quot;
)

# 打印解析结果
print(f&quot;解析完成: {len(elements)} 个元素, {sum(len(str(e)) for e in elements)} 字符&quot;)

# 统计元素类型
from collections import Counter
types = Counter(e.category for e in elements)
print(f&quot;元素类型: {dict(types)}&quot;)

# 显示所有元素
print(&quot;\n所有元素:&quot;)
for i, element in enumerate(elements, 1):
    print(f&quot;Element {i} ({element.category}):&quot;)
    print(element)
    print(&quot;=&quot; * 60)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-3.BlZvQwyI.png&amp;amp;w=1510&amp;amp;h=1244&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-4.BxKANfjE.png&amp;amp;w=1512&amp;amp;h=1230&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;不过这里的运行结果其实一般，首先是Could not get FontBBox...，通常是PDF里字体数据不规范；然后，No languages specified, defaulting to English 和一堆 short text... Defaulting to English 也不是报错，只是说明它没拿到语言参数，默认按英文处理。&lt;/p&gt;
&lt;p&gt;partition 函数参数解析：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;filename: 文档文件路径，支持本地文件路径；&lt;/li&gt;
&lt;li&gt;content_type: 可选参数，指定MIME类型（如&quot;application/pdf&quot;），可绕过自动文件类型检测；&lt;/li&gt;
&lt;li&gt;file: 可选参数，文件对象，与 filename 二选一使用；&lt;/li&gt;
&lt;li&gt;url: 可选参数，远程文档 URL，支持直接处理网络文档；&lt;/li&gt;
&lt;li&gt;include_page_breaks: 布尔值，是否在输出中包含页面分隔符；&lt;/li&gt;
&lt;li&gt;strategy: 处理策略，可选 &quot;auto&quot;、&quot;fast&quot;、&quot;hi_res&quot; 等；&lt;/li&gt;
&lt;li&gt;encoding: 文本编码格式，默认自动检测。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果要更好的处理，可以直接&lt;code&gt;from unstructured.partition.pdf import partition_pdf&lt;/code&gt;用专门的pdf包，提供方更多特有的参数选项，如OCR语言设置、图像提取、表格结构推理等高级性能，同时性能更优。当我们换用这个包，且使用his_res之后，明显效果好多了，NarrativeText 从之前很少，变成了 68 个，正文识别明显更好了；出现了 Table、FigureCaption、Image，说明版面理解生效了；像“历史沿革”“技术定义”“工作流程”下面的大段正文，基本能被连续抽出来了。不过，hi_res需要一些新的系统依赖，比如用于OCR的&lt;a href=&quot;https://tesseract-ocr.github.io/&quot;&gt;Tesseract&lt;/a&gt;、用于PDF的&lt;a href=&quot;https://pdf2image.readthedocs.io/&quot;&gt;Popler&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;而在实际应用中，针对 pdf 的处理，目前更多选用的是 PaddleOCR、MinerU 等模型或工具。&lt;/p&gt;
</content:encoded></item><item><title>策略梯度入门：从定理到 REINFORCE</title><link>https://owen571.top/posts/study/reinforce-learning/04-%E7%AD%96%E7%95%A5%E6%A2%AF%E5%BA%A6-%E4%B8%8E-reinforce/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/04-%E7%AD%96%E7%95%A5%E6%A2%AF%E5%BA%A6-%E4%B8%8E-reinforce/</guid><description>从 value-based 转向 policy-based，理解策略梯度定理、baseline 与 REINFORCE 的核心直觉。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;前面我们已经跨过了RL到三个大的难题: model-base 2 model-free ( 用数据代替建模 ), non-incremental 2 incremental ( 从递推形式变成增量形式 ), tabular representation 2 function representation (当然, 这个目前大多数的解决还是依赖于Policy网络 ). 但是, 还是有些问题无法解决的……&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. 策略梯度定理&lt;/h1&gt;
&lt;p&gt;DQN虽然某些地方获得了成功, 但是其本身还是有许多问题. 比如:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;因为是value-based, 策略是隐式的, 无法表示随机策略. 而某些问题, 随机策略反而是最好的, 需要以不同概率选择不同动作. DQN之类的算法在实现时候采用了贪心策略, 显然无法按照概率执行候选动作.&lt;/li&gt;
&lt;li&gt;Q值的微小改变就会让动作选中、不选中. 举例来说Q值排名前两名的动作可能只相差了0.0001这样子, 增大一点第二位的动作就变成最优了. 所以说, 不稳定, 影响算法收敛.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对于1我们可以再进行举例解释: 下图黑色部分为墙壁, 扫地机器人的功能是避开仓鼠吸灰, 在左侧红色区域时, 机器人可能会向左, 发现仓鼠, 从而向右来吸灰. 但是, 当同样的state发生, 扫地机器人进入右侧红色区域时候, 应该采取的是向左的action, 这就导致了同样的state, 产生了两种不同应该采取的action, 但是机器人却学到的是相同的东西, 陷入了混淆的感知态  (&lt;strong&gt;perceptual aliasing&lt;/strong&gt;) , 混合推导.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251019125703.Dp8eFLEu.png&amp;amp;w=572&amp;amp;h=288&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Q(s, a)的输入是智能体感知态s, 而非环境的真实状态, 所以Q值是与感知态强绑定的. 正确的, 应该在红色区域学到的策略, 并非是向左或向右, 而应该是&lt;strong&gt;一本概率向左, 一半概率向右&lt;/strong&gt; 这种有概率的策略, 确定性策略无法应对非对称问题.&lt;/p&gt;
&lt;p&gt;因此, 我们不学习值函数, 而是采用显示学习策略(policy-based) 这样就可以学习到随机的策略, 而不会一直被卡住.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251019131341.Cl24EUx0.png&amp;amp;w=602&amp;amp;h=335&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;为了解决这个问题, 我们不如采用更直接的方法来直接学习策略, 将策略参数化, 让神经网络学习更新参数$\theta$ , 输出策略$\pi_\theta$. 这个策略是s状态下执行各种动作的概率值, 条件概率. 此时的神经网络输出层的作用类似于多分类问题的softmax回归, 输出的是一个概率分布，只不过这里的概率分布不是用来进行分类, 而是执行动作:&lt;/p&gt;
&lt;p&gt;$$
\pi_{\theta}(s) = P[a|s; \theta] \tag{1.1}
$$&lt;/p&gt;
&lt;p&gt;如何衡量这个动作的概率分布好不好呢 ? 在一系列动作, 或者说一个轨迹&lt;strong&gt;trajectory&lt;/strong&gt;之后, 我们把每一步的奖励累积起来, 来评价这个轨迹的好坏. 我们通过优化预期的累计奖励$J(\theta)$ 这个目标函数, 就可以得到最佳策略.
$$
J(\theta)=\Sigma_\tau P(\tau;\theta)R(\tau)\tag{1.2}
$$
所以, 我们现在就要通过梯度上升的方法, 让$J(\theta)$ 最大, 称为&lt;strong&gt;策略梯度&lt;/strong&gt;. 首先, 我们对其求梯度:
$$
\nabla_\theta J(\theta)=\nabla_\theta \Sigma_\tau P(\tau;\theta)R(\tau)=\Sigma_\tau \nabla_\theta P(\tau;\theta)R(\tau)\tag{1.3}
$$
$$
=\Sigma_\tau \frac{\nabla_\theta P(\tau;\theta)}{P(\tau;\theta)}P(\tau;\theta)R(\tau)\tag{1.4}
$$
而根据对数函数复合函数求导公式:
$$
\nabla_xlogf(x)=\frac{\nabla_x f(x)}{f(x)}\tag{1.5}
$$
我们可以进一步化简公式, 得到:
$$
\nabla_\theta J(\theta)=\Sigma_\tau P(\tau;\theta)\nabla_\theta logP(\tau;\theta)R(\tau) \tag{1.6}
$$
而这个时候, 又会巧妙发现这是符合概率论中期望定义的一个式子, 前面是走其中一个trajectory的概率, 后面是走这个trajectory对应的值. 现在将其写为期望的形式:
$$
\nabla_\theta J(\theta)=E_{\tau \sim P(\tau;\theta)} \Sigma_\tau \nabla_\theta logP(\tau;\theta)R(\theta) \tag{1.7}
$$
继续, 根据大数定律, 我们可以通过采样的方法来近似出对$\theta$ 的梯度, 得到如下的式子:
$$
\nabla_\theta J(\theta)=\frac{1}{m}\sum \limits_{i=1}^{m}\nabla_\theta logP(\tau^{(i)};\theta)R(\tau^{(i)}) \tag{1.8}
$$
其中i表示第i次流程, 这样就可以一轮一轮走过流程, 来训练这个值让他最大. 这样其实已经从宏观把握了如何进行的优化,&lt;/p&gt;
&lt;p&gt;继续化简, 其中$P(\tau ^{i};\theta)$ 是一个链条, 表示在$\pi(\theta)$ 这个策略下 , 选择$\tau$ 这个trajectory的概率:&lt;/p&gt;
&lt;p&gt;$$
P(\tau;\theta)=\Pi_{t=0}P(s_{t+1}|s_t;a_t)\pi_\theta(a_t|s_t) \tag{1.9}
$$
其中第i次采样的结果是:
$$
P(\tau ^ {i};\theta)=\mu(s_0)\prod\limits_{t=0}^{H} P(s_{t+1}^{(i)}|s_{t}^{(i)}, a_{t}^{(i)})\pi_\theta(s_{t}^{(i)}, a_{t}^{(i)}) \tag{1.10}
$$
这里说明一下, 在第i次采样中$\theta$ 是$s_t^{(i)}$ 中$a_t^{(i)}$ 对可能性是$\pi_\theta(s_{t}^{(i)}, a_{t}^{(i)})$, 但即时该动作也可能到不了$s_{t+1}$, 举个例子如果路滑可能就会多走一格. 这取决于环境的反馈概率, 也就是$P(s_{t+1}^{(i)}|s_{t}^{(i)}, a_{t}^{(i)})$ . 而$\mu(s_0)$ 则是代表以$s_0$ 开始的概率, 或者说$s_0$ 的概率分布, 因为某些环境中起点都有可能. 所以从$\mu$ 开始, 累乘后面的式子到结束, 就是一个trajectory的P.&lt;/p&gt;
&lt;p&gt;考虑形式, 两边取对数对$\theta$ 求梯度, 用对数性质化简:
$$
\begin{aligned}&amp;amp;\nabla_\theta\log P(\tau^{(i)};\theta)=\nabla_\theta\log\left[\mu(s_0)\prod_{t=0}^HP\left(s_{t+1}^{(i)}\mid s_t^{(i)},a_t^{(i)}\right)\pi_\theta\left(a_t^{(i)}\mid s_t^{(i)}\right)\right]\&lt;/p&gt;
&lt;p&gt;&amp;amp;\nabla_\theta\log P(\tau^{(i)};\theta)=\nabla_\theta\left[\log\mu(s_0)+\sum_{t=0}^H\log P\left(s_{t+1}^{(i)}\mid s_t^{(i)},a_t^{(i)}\right)+\sum_{t=0}^H\log\pi_\theta\left(a_t^{(i)}\mid s_t^{(i)}\right)\right]\end{aligned}\tag{1.11}
$$&lt;/p&gt;
&lt;p&gt;而右侧的式子, 根据和的梯度就等于梯度的和, 继续化简:&lt;/p&gt;
&lt;p&gt;$$
\begin{aligned}&amp;amp;\nabla_\theta\log P(\tau^{(i)};\theta)=\nabla_\theta\log\mu(s_0)+\nabla_\theta\sum_{t=0}^H\log P\left(s_{t+1}^{(i)}\mid s_t^{(i)},a_t^{(i)}\right)+\nabla_\theta\sum_{t=0}^H\log\pi_\theta\left(a_t^{(i)}\mid s_t^{(i)}\right)\&lt;/p&gt;
&lt;p&gt;&amp;amp;\nabla_\theta\log P(\tau^{(i)};\theta)=\nabla_\theta\sum_{t=0}^H\log\pi_\theta\left(a_t^{(i)}\mid s_t^{(i)}\right)\\end{aligned}\tag{1.12}
$$&lt;/p&gt;
&lt;p&gt;综合一下, 将1.12式代入1.8式子,  最终就得到了$J(\theta)$ 的梯度.
$$
\nabla_\theta J(\theta)=\hat{g}=\frac1m\sum_{i=1}^m\sum_{t=0}^H\nabla_\theta\log\pi_\theta\left(a_t^{(i)}\mid s_t^{(i)}\right)R(\tau^{(i)})\tag{1.13}
$$
上面其实是m次采样之后的策略梯度. 我们也可以将本式子再回归本源, 写回期望形式:
$$
\nabla_\theta J(\theta)=\mathbb{E}&lt;em&gt;{\pi&lt;/em&gt;\theta}[\nabla_\theta log\pi_\theta (a_t|s_t)R(\tau)]\tag{1.14}
$$&lt;/p&gt;
&lt;p&gt;这个式子, 就是我们需要的&lt;strong&gt;策略梯度定理&lt;/strong&gt; ! 虽然我们进行了非常繁琐的推理, 但是这个期望式子意外的非常简洁. 值得注意的是, 这里的$\nabla_\theta log\pi_\theta (a_t|s_t)$  在统计学上被称为&lt;strong&gt;得分函数 (Score Function)&lt;/strong&gt;, 它的定义就是对数似然函数对某个参数的偏导数. 这个量有一些很有趣的数学性质.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;得分函数在真实参数下的期望为0. 这是因为对数似然函数的导数反映了似然函数的“坡度”, 而在真实参数$\theta$ 下, 似然函数达到极大值, 坡度为0.&lt;/li&gt;
&lt;li&gt;得分函数的方差不为0, 而是与Fisher信息密切相关.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当然上面的性质不用管, 只需要认识到它是一个重要的统计量, 蕴含着更深入的信息. 比如在求解MLE时, 我们就是通过求解得分函数等于零的点来估计参数.&lt;/p&gt;
&lt;p&gt;回到这个式子, 对于概率分布$\pi_\theta(a|s)$ , 得分函数定义为该分布的对数似然关于参数$\theta$ 的梯度. 我们可以把它看作是&lt;strong&gt;在策略空间中指向增加特定动作$a_t$ 概率的方向&lt;/strong&gt;. 这个值指导我们, 如何微调参数$\theta$ , 才能最有效地增加/减少选择特定动作$a_t$ 的概率. 如果整个轨迹的回报$R(\tau)$ 是正的, 就沿着这个方向更新; 如果是负的, 就反方向更新.&lt;/p&gt;
&lt;p&gt;而且, 这个式子还有自动调节步长的能力. 在概率较低的动作上, 得分函数的幅度较大 (可以想象log图像) , 更新步长也就越大. 换言之, 重视那些很少被选中但是有潜力的动作, 在策略梯度中起到&lt;strong&gt;权重调节器&lt;/strong&gt;的作用.&lt;/p&gt;
&lt;h1&gt;二. 策略梯度的实现技巧&lt;/h1&gt;
&lt;h2&gt;1. 基线 (baseline)&lt;/h2&gt;
&lt;p&gt;基线通常是一个常数或函数, 用于对轨迹回报进行调整, 将回报转换为对基线的&lt;strong&gt;优势 (advantage)&lt;/strong&gt;, 这也是&lt;strong&gt;优势函数&lt;/strong&gt;中优势一词的来源 (优势函数是后面AC框架的重要概念, 我们将会在下一节介绍) .&lt;/p&gt;
&lt;p&gt;引入baseline的好处不止除了从减小方差的方向理解, 我们观察如下例子,  假设在某个状态有三个动作a、b、c可以执行. 根据式子1.13, 我们要把这三个动作的概率, 对数概率都提高. 但是它们前面的权重$R(\tau)$ 是不一样的, 权重有大有小. 权重小的, 该动作的概率提升的就少, 权重大的概率更提升的就大. 但是对数概率是一个概率, 所以对数概率和肯定是log1也就是0. 因此, 提高少的, 在做完归一化之后会发现居然是下降的, 提升多的才会上升.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251107010519.ALnII9W4.png&amp;amp;w=818&amp;amp;h=226&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;而即使是这样, 也还是理想的状态. 因为我们假设a、b、c都被采样到了, 但是实际上我们只采样到了少量的s-a对, 可能有的动作根本没采样到, 那么这些动作就会不断下降, 但是没被采样的明明不一定是不好的动作.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251107010652.BZME_wDD.png&amp;amp;w=796&amp;amp;h=216&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;为了解决这个问题, 我们可以让奖励不总是正的, 通过把奖励减去b的方法, 让$R(\tau)&amp;gt;b$ 的时候, 概率就上升, 反之概率就下降. 至于b怎么设置, 我们可以对$R(\tau)$ 的值取期望, 拿到一个“平均”的准线, 也就是说:
$$
b \approx E[R(\tau)] \tag{2.1.1}
$$&lt;/p&gt;
&lt;p&gt;使用了这一技巧的公式可以写为:
$$
\nabla\bar{R}&lt;em&gt;{\theta}\approx\frac{1}{N}\sum&lt;/em&gt;{n=1}^{N}\sum_{t=1}^{T_{n}}\left( R\left(\tau^{n}\right)-b\right)\nabla\log p_{\theta}\left(a_{t}^{n}\mid s_{t}^{n}\right) \tag{2.1.2}
$$&lt;/p&gt;
&lt;h2&gt;2. 分配合适的分数&lt;/h2&gt;
&lt;p&gt;除了基线之外, 策略梯度算法还有另外一种实现的技巧, 那就是给每一个动作分配合适的分数 (credit). 这是因为, 在同一场游戏里, 我们对所有的状态-动作对使用同样的奖励项进行加权.&lt;/p&gt;
&lt;p&gt;这显然是不公平的, 在同一场游戏里面, 也许有些动作是好的, 有些动作是不好的. 假设整场游戏的结果是好的, 但并不代表这场游戏里面每一个动作都是好的, 反之亦然. 所以我们希望可以给每一个不同的动作前面都乘上不同的权重. 我们再像之前value-based一样, 给未来的奖励做一个折扣, 式子就变成了:
$$
\nabla\bar{R}&lt;em&gt;{\theta}\approx\frac{1}{N}\sum&lt;/em&gt;{n=1}^{N}\sum_{t=1}^{T_{n}}\left( \sum_{t^{\prime}=t}^{T_{n}}\gamma^{t^{\prime}-t}r_{t^{\prime}}^{n}-b\right) \nabla\log p_{\theta}\left(a_{t}^{n}\mid s_{t}^{n}\right) \tag{2.1.3}
$$&lt;/p&gt;
&lt;h1&gt;三. REINFORCE算法(蒙特卡洛策略梯度)&lt;/h1&gt;
&lt;p&gt;在前面的讨论中, 我们已经推导出了策略梯度采样下的形式, 并对其进行了优化得到了2.1.3. 既然是采样, 我们可以与value-based一开始一样, 采用MC的方法对梯度奖励进行估计.&lt;/p&gt;
&lt;p&gt;显然, 我们的分配后合适奖励的部分, 可以继续写成带折扣回报 (return)的形式, 即第n次采样时间步t的回报为$G_t^n$ (为了区分, 我们把蒙特卡洛采样的总回报用G表示而不是R ). 然后我们就可以将其写为:
$$
\nabla\bar{R}&lt;em&gt;{\theta}\approx\frac{1}{N}\sum&lt;/em&gt;{n=1}^{N}\sum_{t=1}^{T_{n}}G_{t}^ {n}\nabla\log\pi_{\theta}\left(a_{t}^{n}\mid s_{t}^{n}\right) \tag{3.1}
$$
Reinforce算法是Williams提出的经典策略梯度算法之一, 其步骤如下:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用当前策略走一条完整的轨迹&lt;/li&gt;
&lt;li&gt;倒退计算这条轨迹中每个时间步的累计回报&lt;/li&gt;
&lt;li&gt;相乘得到一条轨迹的梯度估计. (即上述梯度公式)&lt;/li&gt;
&lt;li&gt;沿着梯度上升的方向更新策略 $\theta \leftarrow \theta+\alpha \hat{g}$&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我们举一个具体的例子:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;假设我们在玩一个简单的游戏，状态 s 是当前游戏画面的像素（一个向量或张量）。可能的动作为：[“上”, “下”, “左”, “右”]
1. 构建策略网络：
我们设计一个神经网络，输入层接收状态 s（像素数据）。
中间有若干隐藏层。
输出层有4个神经元，分别对应4个动作。
最后通过一个 Softmax 激活函数，将这4个神经元的输出值转换成一个概率分布（所有输出值之和为1）。
2. 定义参数 θ：
这个网络里所有的连接权重（Weights）和偏置（Bias），从输入层到第一个隐藏层，一直到输出层，所有这些数字，共同构成了参数向量 θ。
3.策略的执行：
当智能体处于某个状态 s_t 时，它将 s_t 输入网络。
网络根据当前的参数 θ 进行计算，在输出层得到一个概率分布，例如 [0.1, 0.7, 0.1, 0.1]。
智能体就按照这个概率分布随机选择一个动作（比如有70%的概率选择“下”）。这就是你提到的“随机的策略，而不会一直被卡住”的体现。
4.策略的更新（学习）：
智能体执行动作，从环境中获得奖励（Reward），并进入新的状态。
经过一系列这样的交互（一个轨迹），我们通过策略梯度 等算法来计算：如果稍微调整参数 θ，是否能使得获得的总奖励增加？
然后，我们使用梯度上升法来更新参数 θ（例如：θ = θ + α * ∇J(θ)，其中 ∇J(θ) 是策略梯度）。
θ 更新后，我们的“策略机器”就发生了改变。对于同一个状态 s，网络会输出一个新的、期望能带来更高奖励的概率分布。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以看出, 早期的REINFORCE算法, 梯度上就等于$\nabla_\theta log\pi_\theta (a_t|s_t)G(\tau)$ , 方差很大. 当使用上述的基线来进行优化变成了REINFORCE with baseline算法时, 就已经产生了优势的雏形了.&lt;/p&gt;
</content:encoded></item><item><title>FastAPI 组织逻辑：Depends、yield、错误处理与安全起步</title><link>https://owen571.top/posts/study/fastapi/07-fastapi-%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5-%E9%94%99%E8%AF%AF%E5%A4%84%E7%90%86%E4%B8%8E%E5%AE%89%E5%85%A8%E8%B5%B7%E6%AD%A5/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/07-fastapi-%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5-%E9%94%99%E8%AF%AF%E5%A4%84%E7%90%86%E4%B8%8E%E5%AE%89%E5%85%A8%E8%B5%B7%E6%AD%A5/</guid><description>从 Depends 开始，把共享逻辑、yield 资源清理、HTTPException、自定义异常处理和 OAuth2PasswordBearer 串成一层。</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前面几篇主要在搭“接口表层”。到了这里，FastAPI 开始真正长出工程味：共享逻辑、资源生命周期、认证入口和错误通道都在这一层。&lt;/p&gt;
&lt;h2&gt;1. &lt;code&gt;Depends&lt;/code&gt; 在干什么&lt;/h2&gt;
&lt;p&gt;官方把依赖项单独拆成一章，这一章非常关键，因为 FastAPI 的很多高级能力都站在它上面。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import Depends, FastAPI

app = FastAPI()


async def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100):
    return {&quot;q&quot;: q, &quot;skip&quot;: skip, &quot;limit&quot;: limit}


@app.get(&quot;/items/&quot;)
async def read_items(commons: Annotated[dict, Depends(common_parameters)]):
    return commons


@app.get(&quot;/users/&quot;)
async def read_users(commons: Annotated[dict, Depends(common_parameters)]):
    return commons
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里可以直接把依赖注入理解成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;路径函数声明“我需要什么”&lt;/li&gt;
&lt;li&gt;FastAPI 负责先把这段逻辑跑完&lt;/li&gt;
&lt;li&gt;再把结果注进来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它最值的地方在于复用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;共享查询参数&lt;/li&gt;
&lt;li&gt;共享数据库会话&lt;/li&gt;
&lt;li&gt;共享认证逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. &lt;code&gt;Depends&lt;/code&gt; 里传的其实是可调用对象&lt;/h2&gt;
&lt;p&gt;官方文档明确提到，&lt;code&gt;Depends()&lt;/code&gt; 里只接受一个参数，而且这个参数必须是可调用对象。你不需要自己加括号去调用它，FastAPI 会负责调用。&lt;br /&gt;
来源：Dependencies 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/dependencies/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/dependencies/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;所以依赖不一定非得是函数，也可以是类。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import Depends, FastAPI

app = FastAPI()


class CommonQueryParams:
    def __init__(self, q: str | None = None, skip: int = 0, limit: int = 100):
        self.q = q
        self.skip = skip
        self.limit = limit


@app.get(&quot;/items/&quot;)
async def read_items(
    commons: Annotated[CommonQueryParams, Depends(CommonQueryParams)],
):
    return {&quot;q&quot;: commons.q, &quot;skip&quot;: commons.skip, &quot;limit&quot;: commons.limit}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类写法的优势主要在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;编辑器补全更自然&lt;/li&gt;
&lt;li&gt;组织多参数依赖时更清楚&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 子依赖和依赖缓存&lt;/h2&gt;
&lt;p&gt;依赖还可以继续依赖别的依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import Cookie, Depends, FastAPI

app = FastAPI()


def query_extractor(q: str | None = None):
    return q


def query_or_cookie_extractor(
    q: Annotated[str | None, Depends(query_extractor)],
    last_query: Annotated[str | None, Cookie()] = None,
):
    if not q:
        return last_query
    return q


@app.get(&quot;/items/&quot;)
async def read_query(
    query_or_default: Annotated[str | None, Depends(query_or_cookie_extractor)],
):
    return {&quot;q_or_cookie&quot;: query_or_default}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而且同一个请求里，FastAPI 不会重复计算同一个依赖结果，而是会缓存并复用。&lt;/p&gt;
&lt;h2&gt;4. 装饰器依赖和全局依赖&lt;/h2&gt;
&lt;p&gt;有些依赖不是为了把值注入进函数，而是为了让某个检查逻辑在进入路由前一定执行。这时可以放到装饰器上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException

app = FastAPI()


async def verify_token(x_token: Annotated[str, Header()]):
    if x_token != &quot;fake-super-secret-token&quot;:
        raise HTTPException(status_code=400, detail=&quot;X-Token header invalid&quot;)


@app.get(&quot;/items/&quot;, dependencies=[Depends(verify_token)])
async def read_items():
    return [{&quot;item&quot;: &quot;Foo&quot;}, {&quot;item&quot;: &quot;Bar&quot;}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果整个应用都需要某个依赖，也可以直接写到 &lt;code&gt;FastAPI(...)&lt;/code&gt; 上。&lt;/p&gt;
&lt;h2&gt;5. &lt;code&gt;yield&lt;/code&gt; 依赖：提供资源，也负责回收资源&lt;/h2&gt;
&lt;p&gt;你本地 19.md 这部分其实已经抓到重点了：&lt;code&gt;yield&lt;/code&gt; 依赖不是返回一个值然后结束，而是先把值交出去，等请求处理完，再回来执行清理逻辑。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def get_db():
    db = DBSession()
    try:
        yield db
    finally:
        db.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以直接把它理解成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;yield&lt;/code&gt; 前：准备资源&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yield&lt;/code&gt; 出去：把资源交给路径函数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yield&lt;/code&gt; 后：做清理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最典型的场景就是数据库会话、文件句柄、临时连接。&lt;/p&gt;
&lt;h2&gt;6. 为什么 &lt;code&gt;try/finally&lt;/code&gt; 总和 &lt;code&gt;yield&lt;/code&gt; 一起出现&lt;/h2&gt;
&lt;p&gt;因为 &lt;code&gt;finally&lt;/code&gt; 能保证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;即使中间抛异常&lt;/li&gt;
&lt;li&gt;即使路径函数失败&lt;/li&gt;
&lt;li&gt;清理逻辑也最终会执行&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一点在资源管理里很重要，不然连接和会话很容易泄漏。&lt;/p&gt;
&lt;h2&gt;7. 错误处理：先从 &lt;code&gt;HTTPException&lt;/code&gt; 开始&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI, HTTPException

app = FastAPI()

items = {&quot;foo&quot;: &quot;The Foo Wrestlers&quot;}


@app.get(&quot;/items/{item_id}&quot;)
async def read_item(item_id: str):
    if item_id not in items:
        raise HTTPException(status_code=404, detail=&quot;Item not found&quot;)
    return {&quot;item&quot;: items[item_id]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是 FastAPI 最常见的错误出口。&lt;/p&gt;
&lt;p&gt;如果只记一个点，那就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;raise HTTPException(...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比手写响应对象更像“真正的错误通道”。&lt;/p&gt;
&lt;h2&gt;8. 自定义异常处理器&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse


class UnicornException(Exception):
    def __init__(self, name: str):
        self.name = name


app = FastAPI()


@app.exception_handler(UnicornException)
async def unicorn_exception_handler(request: Request, exc: UnicornException):
    return JSONResponse(
        status_code=418,
        content={&quot;message&quot;: f&quot;Oops! {exc.name} did something. There goes a rainbow...&quot;},
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一步意味着错误处理开始从“单个路由里的 if 判断”升级成“全局错误策略”。&lt;/p&gt;
&lt;h2&gt;9. 处理校验错误：&lt;code&gt;RequestValidationError&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;当客户端输入数据不合法时，FastAPI 内部会抛出 &lt;code&gt;RequestValidationError&lt;/code&gt;。这类错误也可以被接管：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import PlainTextResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()


@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
    return PlainTextResponse(str(exc.detail), status_code=exc.status_code)


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc: RequestValidationError):
    message = &quot;Validation errors:&quot;
    for error in exc.errors():
        message += f&quot;\\nField: {error[&apos;loc&apos;]}, Error: {error[&apos;msg&apos;]}&quot;
    return PlainTextResponse(message, status_code=400)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方还特别提到一个细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;业务里抛错用 FastAPI 的 &lt;code&gt;HTTPException&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;但注册异常处理器时，更适合注册 Starlette 的 &lt;code&gt;HTTPException&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为这样连 Starlette 内部抛出的同类错误也能一起接住。&lt;br /&gt;
来源：Handling Errors 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/handling-errors/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/handling-errors/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;10. 安全起步：&lt;code&gt;OAuth2PasswordBearer&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;安全入门那一页最值得先记住的不是完整 OAuth2 流程，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FastAPI 把认证入口也做成了依赖&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import Depends, FastAPI
from fastapi.security import OAuth2PasswordBearer

app = FastAPI()

oauth2_scheme = OAuth2PasswordBearer(tokenUrl=&quot;token&quot;)


@app.get(&quot;/items/&quot;)
async def read_items(token: str = Depends(oauth2_scheme)):
    return {&quot;token&quot;: token}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;tokenUrl=&quot;token&quot;&lt;/code&gt; 指向的是相对路径 &lt;code&gt;./token&lt;/code&gt;，官方文档里专门解释了这一点。&lt;br /&gt;
来源：Security First Steps 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/security/first-steps/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/security/first-steps/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;一旦加上它，&lt;code&gt;/docs&lt;/code&gt; 右上角就会出现 &lt;code&gt;Authorize&lt;/code&gt; 按钮，交互文档会自动进入“可以带认证信息调试”的状态。&lt;/p&gt;
&lt;p&gt;这一页最重要的意义，不是立刻把认证做完，而是先意识到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;安全在 FastAPI 里不是“外挂”&lt;/li&gt;
&lt;li&gt;而是沿着依赖注入系统自然长出来的&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>LangChain 入门：安装、Quick Start 与设计哲学</title><link>https://owen571.top/posts/study/langchain/02-langchain-%E5%85%A5%E9%97%A8%E4%B8%8Equick-start/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/02-langchain-%E5%85%A5%E9%97%A8%E4%B8%8Equick-start/</guid><description>先跑通一个最小 LangChain Agent，再回头看它的设计哲学、生态关系和为什么它不是简单的模型调用封装。</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇是整条学习线的起点文章。它保留了“先搭一个最小 Agent 看全貌”的视角，但我把它放在真正进入各组件之前，让它承担“先看全景图，再拆零件”的作用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍与安装&lt;/h2&gt;
&lt;p&gt;LangChain 是一个用于构建LLM应用的开源开发框架，有Python和Java两种包，注重组合和模块化。利用LangChain，可以创造完全自定义的agents和LLM应用，可以在不到10行代码内连接到OpenAI、Anthropic、Google等。&lt;/p&gt;
&lt;p&gt;关于它和LangGraph、Deep Agents的区别，也给的很详细，大概就是Deep Agents开箱即用；LangChain 代理构建在 LangGraph 之上，以提供持久执行、流媒体、人工干预、持久性等功能。基本使用 LangChain 代理不需要了解 LangGraph，只要需要深度自定义的时候才用LangGraph。&lt;/p&gt;
&lt;p&gt;我们基于&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/overview&quot;&gt;LangChain的官方文档&lt;/a&gt;进行学习。&lt;/p&gt;
&lt;p&gt;先安装一下，然后LangChain说自己对许多LLM有融合，这些融合在独立的包中，所以我们安装一下对OpenAI的支持。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U langchain
pip install -U langchain-openai
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 获得AI coding assistant&lt;/h2&gt;
&lt;p&gt;官网提供了一个LangChain Docs MCP server来帮助你的agent获取最新文档，并提供一个LangChain Skills来帮你提高agent在LangChain ecosystem的表现。我正好有一个codex账号，平时用agent插件辅助编码，现在就接入试试。&lt;/p&gt;
&lt;p&gt;首先按照官网提供的MCP地址，给codex配置，然后用prompt测试连接：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/langchain/image-2.png&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/images/study/langchain/image-3.png&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/images/study/langchain/image-4.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;可以看到，已经成功连接到了LangChain Docs的API。&lt;/p&gt;
&lt;p&gt;然后，我们依旧按照官网地址，给codex提供&lt;a href=&quot;https://github.com/langchain-ai/langchain-skills&quot;&gt;LangChain Skills&lt;/a&gt;。这个skill可以帮助搭建LangChain、LangGraph和Deep Agents。&lt;/p&gt;
&lt;p&gt;npx是Node.js生态里的一个通用命令执行工具，用于直接运行npm包提供的命令。所谓Node.js是JavaScript程序的运行环境，npx skills可以运行一个叫skills的Node.js CLI工具。至于为什么不继续发布到Python的PyPI，主要还是因为JS还有一些自己的好处，我问了问AI总结如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/langchain/image-5.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;总之，只要“support the Agent Skills specification”，也就是说agent支持标准的&lt;a href=&quot;https://agentskills.io/specification&quot;&gt;Agent规范&lt;/a&gt;，就可以用命令添加。Agent Skills的介绍如下：&lt;a href=&quot;https://agentskills.io/home&quot;&gt;Agent Skills文档&lt;/a&gt;，这里按下不表，之后进行学习，不然就跑偏太远了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx skills add langchain-ai/langchain-skills --skill &apos;*&apos; --yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个命令实际上干了两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;临时拉取工具： npx 去网上临时下载了一个名叫 skills 的执行工具（这个工具被塞进了 ~/.npm/_npx 里）。&lt;/li&gt;
&lt;li&gt;执行添加动作： 这个 skills 工具运行了 add 命令，把 langchain-skills 添加到了你的当前工作目录下。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们看看目录，果然多了一堆东西：
&lt;img src=&quot;https://owen571.top/images/study/langchain/image-6.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;总之，这个先这样，现在我们是把skills放在了当前目录下，在这里使用agent可以让它自己去读取。但是其实也可以放~/.agents/skills/一劳永逸，用这样的命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx skills add langchain-ai/langchain-skills --skill &apos;*&apos; --agent codex -g 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行完这条，还默认给我装了Find Skills的Skill，我们检查文件如下：
&lt;img src=&quot;https://owen571.top/images/study/langchain/image-8.png&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/images/study/langchain/image-7.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;里面有点像说明书，暂时不展开，之后应该会细看。&lt;/p&gt;
&lt;h2&gt;3. 搭建一个基础Agent&lt;/h2&gt;
&lt;p&gt;官网给的最小实例，好像是默认你装了OpenAI兼容包，设置了OpenAI的key，而且走官方路径。我们走的中转，环境变量也是在.env中，因此我们自己要设置一下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI

load_dotenv()

model = ChatOpenAI(
    model = &quot;gpt-4o-mini&quot;,
    api_key=os.environ[&quot;QIHANG_API&quot;],
    base_url=os.environ[&quot;QIHANG_BASE_URL&quot;],    
)

def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;
    return f&quot;It&apos;s always sunny in {city}!&quot;

agent = create_agent(
    model=model,
    tools=[get_weather],
    system_prompt=&quot;You are a helpful assistant&quot;,
)

# Run the agent
result = agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;what is the weather in sf&quot;}]}
)

print(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们来看看原始输出的结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;messages&quot;: [
    {
      &quot;type&quot;: &quot;HumanMessage&quot;,
      &quot;content&quot;: &quot;what is the weather in sf&quot;,
      &quot;additional_kwargs&quot;: {},
      &quot;response_metadata&quot;: {},
      &quot;id&quot;: &quot;11c974bc-3204-4e15-9391-40db1cd6c982&quot;
    },
    {
      &quot;type&quot;: &quot;AIMessage&quot;,
      &quot;content&quot;: &quot;&quot;,
      &quot;additional_kwargs&quot;: {
        &quot;refusal&quot;: null
      },
      &quot;response_metadata&quot;: {
        &quot;token_usage&quot;: {
          &quot;completion_tokens&quot;: 16,
          &quot;prompt_tokens&quot;: 56,
          &quot;total_tokens&quot;: 72,
          &quot;completion_tokens_details&quot;: {
            &quot;accepted_prediction_tokens&quot;: 0,
            &quot;audio_tokens&quot;: 0,
            &quot;reasoning_tokens&quot;: 0,
            &quot;rejected_prediction_tokens&quot;: 0
          },
          &quot;prompt_tokens_details&quot;: {
            &quot;audio_tokens&quot;: 0,
            &quot;cached_tokens&quot;: 0
          }
        },
        &quot;model_provider&quot;: &quot;openai&quot;,
        &quot;model_name&quot;: &quot;gpt-4o-mini-2024-07-18&quot;,
        &quot;system_fingerprint&quot;: &quot;fp_eb37e061ec&quot;,
        &quot;id&quot;: &quot;chatcmpl-DNMHzlR2RTmob84VvtgxHEcA2jWwF&quot;,
        &quot;finish_reason&quot;: &quot;tool_calls&quot;,
        &quot;logprobs&quot;: null
      },
      &quot;id&quot;: &quot;lc_run--019d2608-a56b-7991-84f5-3a40addac2bb-0&quot;,
      &quot;tool_calls&quot;: [
        {
          &quot;name&quot;: &quot;get_weather&quot;,
          &quot;args&quot;: {
            &quot;city&quot;: &quot;San Francisco&quot;
          },
          &quot;id&quot;: &quot;call_GHZls5pzrZTFanydz2VJANUb&quot;,
          &quot;type&quot;: &quot;tool_call&quot;
        }
      ],
      &quot;invalid_tool_calls&quot;: [],
      &quot;usage_metadata&quot;: {
        &quot;input_tokens&quot;: 56,
        &quot;output_tokens&quot;: 16,
        &quot;total_tokens&quot;: 72,
        &quot;input_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;cache_read&quot;: 0
        },
        &quot;output_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;reasoning&quot;: 0
        }
      }
    },
    {
      &quot;type&quot;: &quot;ToolMessage&quot;,
      &quot;content&quot;: &quot;It&apos;s always sunny in San Francisco!&quot;,
      &quot;name&quot;: &quot;get_weather&quot;,
      &quot;id&quot;: &quot;f84e1e0b-bf8b-4dcd-bdc2-cd9d40a7c1b2&quot;,
      &quot;tool_call_id&quot;: &quot;call_GHZls5pzrZTFanydz2VJANUb&quot;
    },
    // 下面就是message列表的最后一项，AIMessage
    {
      &quot;type&quot;: &quot;AIMessage&quot;,
      &quot;content&quot;: &quot;The weather in San Francisco is currently sunny!&quot;,
      &quot;additional_kwargs&quot;: {
        &quot;refusal&quot;: null
      },
      &quot;response_metadata&quot;: {
        &quot;token_usage&quot;: {
          &quot;completion_tokens&quot;: 11,
          &quot;prompt_tokens&quot;: 86,
          &quot;total_tokens&quot;: 97,
          &quot;completion_tokens_details&quot;: {
            &quot;accepted_prediction_tokens&quot;: 0,
            &quot;audio_tokens&quot;: 0,
            &quot;reasoning_tokens&quot;: 0,
            &quot;rejected_prediction_tokens&quot;: 0
          },
          &quot;prompt_tokens_details&quot;: {
            &quot;audio_tokens&quot;: 0,
            &quot;cached_tokens&quot;: 0
          }
        },
        &quot;model_provider&quot;: &quot;openai&quot;,
        &quot;model_name&quot;: &quot;gpt-4o-mini-2024-07-18&quot;,
        &quot;system_fingerprint&quot;: &quot;fp_eb37e061ec&quot;,
        &quot;id&quot;: &quot;chatcmpl-DNMI1dEwEqVRaNAX2FI8sUJXZl9nW&quot;,
        &quot;service_tier&quot;: &quot;default&quot;,
        &quot;finish_reason&quot;: &quot;stop&quot;,
        &quot;logprobs&quot;: null
      },
      &quot;id&quot;: &quot;lc_run--019d2608-ae45-7a23-a3d7-79389811c5fc-0&quot;,
      &quot;tool_calls&quot;: [],
      &quot;invalid_tool_calls&quot;: [],
      &quot;usage_metadata&quot;: {
        &quot;input_tokens&quot;: 86,
        &quot;output_tokens&quot;: 11,
        &quot;total_tokens&quot;: 97,
        &quot;input_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;cache_read&quot;: 0
        },
        &quot;output_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;reasoning&quot;: 0
        }
      }
    }
  ]
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是回复体啦，可以看到超级长一串。我们可以将其看为四部分：用户问题、模型判断是否调用工具、工具执行结果、模型基于工具结果的最终回答。我们这里是用了一个假工具告诉Agent总是晴天。&lt;/p&gt;
&lt;p&gt;如果我们只想抽取回复，可以拿&lt;code&gt;result[&quot;messages&quot;][-1].content&lt;/code&gt;来打印，就拿到了AIMessage的content：“The weather in San Francisco is currently sunny!”&lt;/p&gt;
&lt;h2&gt;4. 建立一个真实agent&lt;/h2&gt;
&lt;p&gt;按照官网的build步骤，我们开始构建，这里同样构造一个天气agent（伪）。&lt;/p&gt;
&lt;h3&gt;(1) 定义prompt&lt;/h3&gt;
&lt;p&gt;我们需要一段系统提示词，用于定义agent的角色和行为，需要保持specific和actionable。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Step1 Defines the system prompt
SYSTEM_PROMPT = &quot;&quot;&quot;
以下英文提示词，但是请用中文回答。
You are an expert weather forecaster, who speaks in puns.

You have access to two tools:

- get_weather_for_location: use this to get the weather for a specific location
- get_user_location: use this to get the user&apos;s location

If a user asks you for the weather, make sure you know the location. If you can tell from the question that they mean wherever they are, use the get_user_location tool to find their location.
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 创建工具&lt;/h3&gt;
&lt;p&gt;工具允许模型通过调用您定义的函数与外部系统交互。工具可以依赖于运行时上下文(runtiem context)，并与agent内存进行交互。&lt;/p&gt;
&lt;p&gt;@tool是将一个Python函数注册成LangChain可调用的工具，并读取工具名、参数、描述（docstring说明文档 ，也就是函数上面那一段三引号内部内容）、返回值。@dataclass则是python标准库中的装饰器，表达“只装数据的类”，这样可以自动生成很多样板代码，比如&lt;code&gt;__init__&lt;/code&gt;，你可以直接写&lt;code&gt;ctx = Context(user_id=&quot;1&quot;)&lt;/code&gt;。
。&lt;/p&gt;
&lt;p&gt;官方文档：工具应有详细文档：它们的名称、描述和参数名称成为模型提示的一部分。LangChain 的 @tool 装饰器添加元数据，并通过 ToolRuntime 参数启用运行时注入。请在工具指南中了解更多。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Create tools
from dataclasses import dataclass
from langchain.tools import tool, ToolRuntime

@tool
def get_weather_for_location(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;
    return f&quot;It&apos;s always rainy in {city}!&quot;

@dataclass
class Context:
    &quot;&quot;&quot;Custom runtime context schema.&quot;&quot;&quot;
    user_id: str

@tool
def get_user_location(runtime: ToolRuntime[Context]) -&amp;gt; str:
    &quot;&quot;&quot;Retrieve user information based on user ID.&quot;&quot;&quot;
    user_id = runtime.context.user_id
    return &quot;Shanghai&quot; if user_id == &quot;1&quot; else &quot;Wuhan&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) 配置模型&lt;/h3&gt;
&lt;p&gt;官网给出的方法，是通用对话模型的创建方法，它通过provider适配层+模型名规则识别，来走自动集成的配置。更推荐这么写，方便随时更换模型。我们在模型前面注明provider（不注明走自动判断）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## Configure your model
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model

load_dotenv()

model = init_chat_model(
    model = &quot;openai:gpt-4o-mini&quot;,
    api_key=os.environ[&quot;QIHANG_API&quot;],
    base_url=os.environ[&quot;QIHANG_BASE_URL&quot;],
    temperature=0.5,
    timeout=10,
    max_tokens=1000,    
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(4) 定义回复格式&lt;/h3&gt;
&lt;p&gt;这是可选项，可以定义模型回复的格式。这里也明确说明了，除了dataclass，也可以用Pydantic来定义，LangChain只是需要一个结构化的schema。方便复习，所以我们之类就用Pydantic。&lt;/p&gt;
&lt;p&gt;另外，其实所有dataclass都可以直接换成pydantic，比如Context。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel, Field

class ResponseFormat(BaseModel):
    &quot;&quot;&quot;Response schema for the agent.&quot;&quot;&quot;
    punny_response: str = Field(description=&quot;必须给用户的俏皮回答&quot;)
    weather_conditions: str | None = Field(
        default=None,
        description=&quot;天气补充信息&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(5) 添加记忆&lt;/h3&gt;
&lt;p&gt;为agent添加记忆，以在交互之间保持状态。这使代理能够记住先前的对话和上下文。之后会专门来学习这个记忆模块，现在只做简单了解，把这个InMemorySaver传给Agent。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Add memory
from langgraph.checkpoint.memory import InMemorySaver

checkpointer = InMemorySaver()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(6) 创建和运行agent&lt;/h3&gt;
&lt;p&gt;把前面定义过的一些参数，传进create_agent里面，创建出agent。然后，再定义一个config，调用agent的invoke，传入对话、config和Context，就可以获得回答了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Create and run the agent
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy

agent = create_agent(
    model=model,
    system_prompt=SYSTEM_PROMPT,
    tools=[get_user_location, get_weather_for_location],
    context_schema=Context,
    response_format=ToolStrategy(ResponseFormat),
    checkpointer=checkpointer
)

# `thread_id` is a unique identifier for a given conversation.
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}

response = agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;what is the weather outside?&quot;}]},
    config=config,
    context=Context(user_id=&quot;1&quot;)
)

# print(response[&apos;structured_response&apos;])


# Note that we can continue the conversation using the same `thread_id`.
response = agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;thank you!&quot;}]},
    config=config,
    context=Context(user_id=&quot;1&quot;)
)

print(response)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(7) 分析结果&lt;/h3&gt;
&lt;p&gt;先看一下原始结果是啥样的，我们把直接print(response)整理成了json结果，大概是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;messages&quot;: [
    {
      &quot;type&quot;: &quot;HumanMessage&quot;,
      &quot;content&quot;: &quot;what is the weather outside?&quot;,
      &quot;additional_kwargs&quot;: {},
      &quot;response_metadata&quot;: {},
      &quot;id&quot;: &quot;a0514473-c615-4362-826e-92ec42a63884&quot;
    },
    {
      &quot;type&quot;: &quot;AIMessage&quot;,
      &quot;content&quot;: &quot;&quot;,
      &quot;additional_kwargs&quot;: {
        &quot;refusal&quot;: null
      },
      &quot;response_metadata&quot;: {
        &quot;token_usage&quot;: {
          &quot;completion_tokens&quot;: 12,
          &quot;prompt_tokens&quot;: 224,
          &quot;total_tokens&quot;: 236,
          &quot;completion_tokens_details&quot;: {
            &quot;accepted_prediction_tokens&quot;: 0,
            &quot;audio_tokens&quot;: 0,
            &quot;reasoning_tokens&quot;: 0,
            &quot;rejected_prediction_tokens&quot;: 0
          },
          &quot;prompt_tokens_details&quot;: {
            &quot;audio_tokens&quot;: 0,
            &quot;cached_tokens&quot;: 0
          }
        },
        &quot;model_provider&quot;: &quot;openai&quot;,
        &quot;model_name&quot;: &quot;gpt-4o-mini-2024-07-18&quot;,
        &quot;system_fingerprint&quot;: &quot;fp_eb37e061ec&quot;,
        &quot;id&quot;: &quot;chatcmpl-DNYJbb2YC3bGBabU4cMChBtS8XzEB&quot;,
        &quot;finish_reason&quot;: &quot;tool_calls&quot;,
        &quot;logprobs&quot;: null
      },
      &quot;id&quot;: &quot;lc_run--019d28c9-ddd4-7f83-9f16-b9a82cc51a0e-0&quot;,
      &quot;tool_calls&quot;: [
        {
          &quot;name&quot;: &quot;get_user_location&quot;,
          &quot;args&quot;: {},
          &quot;id&quot;: &quot;call_Yc265lCmfCTiSFb0ywzfbZeX&quot;,
          &quot;type&quot;: &quot;tool_call&quot;
        }
      ],
      &quot;invalid_tool_calls&quot;: [],
      &quot;usage_metadata&quot;: {
        &quot;input_tokens&quot;: 224,
        &quot;output_tokens&quot;: 12,
        &quot;total_tokens&quot;: 236,
        &quot;input_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;cache_read&quot;: 0
        },
        &quot;output_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;reasoning&quot;: 0
        }
      }
    },
    {
      &quot;type&quot;: &quot;ToolMessage&quot;,
      &quot;content&quot;: &quot;Shanghai&quot;,
      &quot;name&quot;: &quot;get_user_location&quot;,
      &quot;id&quot;: &quot;6eb3e154-d329-4497-aa2b-e6ea8803c91b&quot;,
      &quot;tool_call_id&quot;: &quot;call_Yc265lCmfCTiSFb0ywzfbZeX&quot;
    },
    {
      &quot;type&quot;: &quot;AIMessage&quot;,
      &quot;content&quot;: &quot;&quot;,
      &quot;additional_kwargs&quot;: {
        &quot;refusal&quot;: null
      },
      &quot;response_metadata&quot;: {
        &quot;token_usage&quot;: {
          &quot;completion_tokens&quot;: 17,
          &quot;prompt_tokens&quot;: 244,
          &quot;total_tokens&quot;: 261,
          &quot;completion_tokens_details&quot;: {
            &quot;accepted_prediction_tokens&quot;: 0,
            &quot;audio_tokens&quot;: 0,
            &quot;reasoning_tokens&quot;: 0,
            &quot;rejected_prediction_tokens&quot;: 0
          },
          &quot;prompt_tokens_details&quot;: {
            &quot;audio_tokens&quot;: 0,
            &quot;cached_tokens&quot;: 0
          }
        },
        &quot;model_provider&quot;: &quot;openai&quot;,
        &quot;model_name&quot;: &quot;gpt-4o-mini&quot;,
        &quot;system_fingerprint&quot;: &quot;fp_eb37e061ec&quot;,
        &quot;id&quot;: &quot;chatcmpl-DNYJel8PrtJkiYfONkB1Le1TPWQuS&quot;,
        &quot;finish_reason&quot;: &quot;tool_calls&quot;,
        &quot;logprobs&quot;: null
      },
      &quot;id&quot;: &quot;lc_run--019d28ca-1321-7231-a3b3-0ca73eb8e4a8-0&quot;,
      &quot;tool_calls&quot;: [
        {
          &quot;name&quot;: &quot;get_weather_for_location&quot;,
          &quot;args&quot;: {
            &quot;city&quot;: &quot;Shanghai&quot;
          },
          &quot;id&quot;: &quot;call_DuMTODUFSuEZyeC6feJl1q8b&quot;,
          &quot;type&quot;: &quot;tool_call&quot;
        }
      ],
      &quot;invalid_tool_calls&quot;: [],
      &quot;usage_metadata&quot;: {
        &quot;input_tokens&quot;: 244,
        &quot;output_tokens&quot;: 17,
        &quot;total_tokens&quot;: 261,
        &quot;input_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;cache_read&quot;: 0
        },
        &quot;output_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;reasoning&quot;: 0
        }
      }
    },
    {
      &quot;type&quot;: &quot;ToolMessage&quot;,
      &quot;content&quot;: &quot;It&apos;s always rainy in Shanghai!&quot;,
      &quot;name&quot;: &quot;get_weather_for_location&quot;,
      &quot;id&quot;: &quot;131320f4-c2c5-4bba-b153-cfdc1a3d3a38&quot;,
      &quot;tool_call_id&quot;: &quot;call_DuMTODUFSuEZyeC6feJl1q8b&quot;
    },
    {
      &quot;type&quot;: &quot;AIMessage&quot;,
      &quot;content&quot;: &quot;&quot;,
      &quot;additional_kwargs&quot;: {
        &quot;refusal&quot;: null
      },
      &quot;response_metadata&quot;: {
        &quot;token_usage&quot;: {
          &quot;completion_tokens&quot;: 46,
          &quot;prompt_tokens&quot;: 277,
          &quot;total_tokens&quot;: 323,
          &quot;completion_tokens_details&quot;: {
            &quot;accepted_prediction_tokens&quot;: 0,
            &quot;audio_tokens&quot;: 0,
            &quot;reasoning_tokens&quot;: 0,
            &quot;rejected_prediction_tokens&quot;: 0
          },
          &quot;prompt_tokens_details&quot;: {
            &quot;audio_tokens&quot;: 0,
            &quot;cached_tokens&quot;: 0
          }
        },
        &quot;model_provider&quot;: &quot;openai&quot;,
        &quot;model_name&quot;: &quot;gpt-4o-mini-2024-07-18&quot;,
        &quot;system_fingerprint&quot;: &quot;fp_eb37e061ec&quot;,
        &quot;id&quot;: &quot;chatcmpl-DNYJfkI8eMkpScdEUDH2VweZJ8j8J&quot;,
        &quot;finish_reason&quot;: &quot;tool_calls&quot;,
        &quot;logprobs&quot;: null
      },
      &quot;id&quot;: &quot;lc_run--019d28ca-1b81-7e02-b17a-7cc074ac7645-0&quot;,
      &quot;tool_calls&quot;: [
        {
          &quot;name&quot;: &quot;ResponseFormat&quot;,
          &quot;args&quot;: {
            &quot;weather_conditions&quot;: &quot;多雨&quot;,
            &quot;punny_response&quot;: &quot;上海的天气真是让人\&quot;水\&quot;深火热，今天又是个\&quot;下雨天\&quot;！&quot;
          },
          &quot;id&quot;: &quot;call_PzZ5A1CewBlCZFBO60f7AeXs&quot;,
          &quot;type&quot;: &quot;tool_call&quot;
        }
      ],
      &quot;invalid_tool_calls&quot;: [],
      &quot;usage_metadata&quot;: {
        &quot;input_tokens&quot;: 277,
        &quot;output_tokens&quot;: 46,
        &quot;total_tokens&quot;: 323,
        &quot;input_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;cache_read&quot;: 0
        },
        &quot;output_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;reasoning&quot;: 0
        }
      }
    },
    {
      &quot;type&quot;: &quot;ToolMessage&quot;,
      &quot;content&quot;: &quot;Returning structured response: punny_response=&apos;上海的天气真是让人\&quot;水\&quot;深火热，今天又是个\&quot;下雨天\&quot;！&apos; weather_conditions=&apos;多雨&apos;&quot;,
      &quot;name&quot;: &quot;ResponseFormat&quot;,
      &quot;id&quot;: &quot;dbdac10f-0c98-40d7-b688-1f69908b1136&quot;,
      &quot;tool_call_id&quot;: &quot;call_PzZ5A1CewBlCZFBO60f7AeXs&quot;
    },
    {
      &quot;type&quot;: &quot;HumanMessage&quot;,
      &quot;content&quot;: &quot;thank you!&quot;,
      &quot;additional_kwargs&quot;: {},
      &quot;response_metadata&quot;: {},
      &quot;id&quot;: &quot;5b17ed89-3b4e-423f-a0f1-44fac7029215&quot;
    },
    {
      &quot;type&quot;: &quot;AIMessage&quot;,
      &quot;content&quot;: &quot;&quot;,
      &quot;additional_kwargs&quot;: {
        &quot;refusal&quot;: null
      },
      &quot;response_metadata&quot;: {
        &quot;token_usage&quot;: {
          &quot;completion_tokens&quot;: 45,
          &quot;prompt_tokens&quot;: 375,
          &quot;total_tokens&quot;: 420,
          &quot;completion_tokens_details&quot;: {
            &quot;accepted_prediction_tokens&quot;: 0,
            &quot;audio_tokens&quot;: 0,
            &quot;reasoning_tokens&quot;: 0,
            &quot;rejected_prediction_tokens&quot;: 0
          },
          &quot;prompt_tokens_details&quot;: {
            &quot;audio_tokens&quot;: 0,
            &quot;cached_tokens&quot;: 0
          }
        },
        &quot;model_provider&quot;: &quot;openai&quot;,
        &quot;model_name&quot;: &quot;gpt-4o-mini&quot;,
        &quot;system_fingerprint&quot;: &quot;fp_eb37e061ec&quot;,
        &quot;id&quot;: &quot;chatcmpl-DNYJhKUMQBmPsNDE56FyIE4yZ1fPY&quot;,
        &quot;finish_reason&quot;: &quot;tool_calls&quot;,
        &quot;logprobs&quot;: null
      },
      &quot;id&quot;: &quot;lc_run--019d28ca-21e5-7812-86af-c7556b1cd64c-0&quot;,
      &quot;tool_calls&quot;: [
        {
          &quot;name&quot;: &quot;ResponseFormat&quot;,
          &quot;args&quot;: {
            &quot;punny_response&quot;: &quot;不客气，\&quot;天气\&quot;如人心，\&quot;风\&quot;云变幻！希望你有个\&quot;晴\&quot;朗的一天！&quot;
          },
          &quot;id&quot;: &quot;call_OjxJ1IqfxM4zzzABiNUTwE1E&quot;,
          &quot;type&quot;: &quot;tool_call&quot;
        }
      ],
      &quot;invalid_tool_calls&quot;: [],
      &quot;usage_metadata&quot;: {
        &quot;input_tokens&quot;: 375,
        &quot;output_tokens&quot;: 45,
        &quot;total_tokens&quot;: 420,
        &quot;input_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;cache_read&quot;: 0
        },
        &quot;output_token_details&quot;: {
          &quot;audio&quot;: 0,
          &quot;reasoning&quot;: 0
        }
      }
    },
    {
      &quot;type&quot;: &quot;ToolMessage&quot;,
      &quot;content&quot;: &quot;Returning structured response: punny_response=&apos;不客气，\&quot;天气\&quot;如人心，\&quot;风\&quot;云变幻！希望你有个\&quot;晴\&quot;朗的一天！&apos; weather_conditions=None&quot;,
      &quot;name&quot;: &quot;ResponseFormat&quot;,
      &quot;id&quot;: &quot;0ed7a498-f2ae-4bee-8531-77181a3c51ee&quot;,
      &quot;tool_call_id&quot;: &quot;call_OjxJ1IqfxM4zzzABiNUTwE1E&quot;
    }
  ],
  &quot;structured_response&quot;: {
    &quot;type&quot;: &quot;ResponseFormat&quot;,
    &quot;punny_response&quot;: &quot;不客气，\&quot;天气\&quot;如人心，\&quot;风\&quot;云变幻！希望你有个\&quot;晴\&quot;朗的一天！&quot;,
    &quot;weather_conditions&quot;: null
  }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好，其实一路看下来还是比较清晰的结果。注意几个点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;因为写了&lt;code&gt;response_format=ToolStrategy(ResponseFormat)&lt;/code&gt;，所以LangChain为了拿到结构化输出，把这个schema包装成了类似工具调用的内部步骤。&lt;/li&gt;
&lt;li&gt;打印response不只是本轮新增内容，而是整个thread的当前状态。我们发送了一个问天气的消息，又发送了一个thank you，最后会打印整个线程所有对话。&lt;/li&gt;
&lt;li&gt;ResponseFormat里面的description，不仅仅是给人看的主食，也参与到模型结构化输出约束中。它跟system_prompt的起效层级不一样，约束力没那么强硬，算是进一步引导。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当然，我们可以指定字段，防止输出这么一长串有点傻的东西。我们用&lt;code&gt;response[&apos;structured_response&apos;]&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/images/study/langchain/image-9.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;注意到这里其实有个警告，“正在从 checkpoint 反序列化一个未注册的类型 &lt;code&gt;__main__.ResponseFormat&lt;/code&gt;。”，未来的版本或许不允许这样用。意思是如果确认这个类型是安全且允许回复，要加入 &lt;code&gt;allowed_msgpack_modules&lt;/code&gt; 白名单。&lt;/p&gt;
&lt;p&gt;消除这个警告的方法也很简单，将ResponseFormat放大单独的模块中，比如就叫schemas.py，然后再导包进来，这样就不是&lt;code&gt;__main__.ResponseFormat&lt;/code&gt;而是&lt;code&gt;schemas.ResponseFormat&lt;/code&gt;了。&lt;/p&gt;
&lt;h2&gt;5. 设计哲学&lt;/h2&gt;
&lt;p&gt;在&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/philosophy&quot;&gt;此界面&lt;/a&gt;提了一下，我这里简单概述一下。&lt;/p&gt;
&lt;p&gt;这一页不是在教具体 API 怎么写，而是在解释 LangChain 想成为什么样的框架，以及它为什么这样设计。&lt;/p&gt;
&lt;p&gt;官方的核心意思可以概括为：LangChain 想成为“构建带上下文能力和推理能力的应用的最简单方式”。这里说的不是只调用一次模型，而是构建完整应用，让模型能读上下文、调工具、输出结构化结果，并在真实项目中持续运行。&lt;/p&gt;
&lt;h3&gt;(1) 从简单开始，但可以扩展到复杂应用&lt;/h3&gt;
&lt;p&gt;LangChain 希望开发者一开始就能用很少的代码搭起一个能运行的 agent 或 LLM 应用，而不是先学习大量底层细节。但它又不想只适合 demo，因此同一套框架要能继续扩展到更复杂的生产场景。&lt;/p&gt;
&lt;p&gt;我的理解是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;入门时可以先用高层封装快速起步&lt;/li&gt;
&lt;li&gt;后面需求变复杂时，不需要整套推倒重来&lt;/li&gt;
&lt;li&gt;可以逐步加入工具、结构化输出、记忆、检索和工作流&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(2) 提供高层抽象，但不要把开发者困在黑盒里&lt;/h3&gt;
&lt;p&gt;LangChain 的哲学不是“把一切都藏起来”，而是默认给你高层接口来提升开发效率，同时保留足够的可控性。&lt;/p&gt;
&lt;p&gt;所以它的思路通常是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;常见任务给出简单接口&lt;/li&gt;
&lt;li&gt;复杂需求允许向下深入&lt;/li&gt;
&lt;li&gt;当高层抽象不够时，可以转向 LangGraph 做更细粒度的编排&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，LangChain 追求的是“默认简单，但不牺牲控制力”。&lt;/p&gt;
&lt;h3&gt;(3) 设计重点是现实中的 agent 应用&lt;/h3&gt;
&lt;p&gt;LangChain 关注的不是孤立的一次 prompt 调用，而是一个真实 AI 应用从输入到输出的完整过程。例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如何连接不同模型提供商&lt;/li&gt;
&lt;li&gt;如何让模型调用工具&lt;/li&gt;
&lt;li&gt;如何管理上下文&lt;/li&gt;
&lt;li&gt;如何拿到结构化输出&lt;/li&gt;
&lt;li&gt;如何调试和观测 agent 的行为&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此它很多设计都围绕“让 agent 应用真正可用”展开，而不是只服务于演示性质的 prompt 实验。&lt;/p&gt;
&lt;h3&gt;(4) 尽量与具体模型提供商解耦&lt;/h3&gt;
&lt;p&gt;LangChain 希望应用逻辑不要被某一家模型提供商强绑定。也就是说，如果底层模型从 OpenAI 换成 Anthropic、Google 等，开发者最好还能保留大部分上层逻辑。&lt;/p&gt;
&lt;p&gt;这也是为什么文档里会不断强调：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;统一的模型初始化方式&lt;/li&gt;
&lt;li&gt;统一的消息接口&lt;/li&gt;
&lt;li&gt;统一的工具调用模式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它想减少“换模型就重写程序”的成本。&lt;/p&gt;
&lt;h3&gt;(5) 重视生产可用性，而不只是能跑&lt;/h3&gt;
&lt;p&gt;LangChain 的目标不是“代码能执行一次就行”，而是希望它能走向真实项目。所以它会特别重视：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;tracing&lt;/li&gt;
&lt;li&gt;observability&lt;/li&gt;
&lt;li&gt;debugging&lt;/li&gt;
&lt;li&gt;structured output&lt;/li&gt;
&lt;li&gt;与 LangSmith / LangGraph 的协作&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，一个应用不仅要能回答问题，还应该能被追踪、分析、调试和维护。&lt;/p&gt;
&lt;h3&gt;(6) LangChain 和 LangGraph 的关系&lt;/h3&gt;
&lt;p&gt;从设计哲学上看，LangChain 更偏向“让构建 agent 更容易”，而 LangGraph 更偏向“提供底层运行时和编排能力”。&lt;/p&gt;
&lt;p&gt;可以这样理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LangChain：高层、上手快、常见场景更省心&lt;/li&gt;
&lt;li&gt;LangGraph：底层、可控性更强、适合复杂流程&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以官方常见的建议是：先从 LangChain 入门，当需求需要更复杂的控制时，再下沉到 LangGraph。&lt;/p&gt;
&lt;h3&gt;(7) 我的总结&lt;/h3&gt;
&lt;p&gt;这页 Philosophy 本质上是在告诉读者：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LangChain 不只是模型调用封装&lt;/li&gt;
&lt;li&gt;它更像一个 AI 应用框架&lt;/li&gt;
&lt;li&gt;它强调易用性，但不想把开发者锁死在黑盒里&lt;/li&gt;
&lt;li&gt;它的很多抽象，都是为了让应用可以从 demo 平滑过渡到真实项目&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>LangGraph 应用思路 01：从流程到 Agent 架构</title><link>https://owen571.top/posts/study/langgraph/09-langgraph-%E6%9E%84%E5%BB%BA-agent-%E6%80%9D%E8%B7%AF/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/09-langgraph-%E6%9E%84%E5%BB%BA-agent-%E6%80%9D%E8%B7%AF/</guid><description>先画流程，再拆成节点、定义 state、补齐错误处理，最后再落到可运行的图。</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;用LangGraph构建Agent的思路&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;本章提供一种构建自己Agent的一种思路入手&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 从想要的自动化流程入手&lt;/h2&gt;
&lt;p&gt;例如，你需要构建一个用于处理客户支持邮件的 AI 智能体。产品团队向你提出了以下需求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The agent should:

- Read incoming customer emails
- Classify them by urgency and topic
- Search relevant documentation to answer questions
- Draft appropriate responses
- Escalate complex issues to human agents
- Schedule follow-ups when needed

Example scenarios to handle:

1. Simple product question: &quot;How do I reset my password?&quot;
2. Bug report: &quot;The export feature crashes when I select PDF format&quot;
3. Urgent billing issue: &quot;I was charged twice for my subscription!&quot;
4. Feature request: &quot;Can you add dark mode to the mobile app?&quot;
5. Complex technical issue: &quot;Our API integration fails intermittently with 504 errors&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 拆解为独立步骤&lt;/h2&gt;
&lt;p&gt;首先明确流程中的各个独立步骤，每个步骤将成为一个节点（执行单一具体功能的函数）。然后勾勒出这些步骤之间的连接关系。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-11.C0KcqsTJ.png&amp;amp;w=1078&amp;amp;h=1298&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;此图表中的箭头表示可能的路径，但具体选择哪条路径的决策在每个节点内部完成。
既然我们已经确定了工作流中的各个组件，接下来了解每个节点需要执行的操作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Read Email：提取并解析邮件内容&lt;/li&gt;
&lt;li&gt;Classify Intent：使用大语言模型对紧急程度和主题进行分类，然后路由至相应操作&lt;/li&gt;
&lt;li&gt;Doc Search：在知识库中查询相关信息&lt;/li&gt;
&lt;li&gt;Bug Track：在跟踪系统中创建或更新问题&lt;/li&gt;
&lt;li&gt;Draft Reply：生成合适的回复内容&lt;/li&gt;
&lt;li&gt;Human Review：转交人工坐席进行审批或处理&lt;/li&gt;
&lt;li&gt;Send Reply：发送邮件回复&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 每一步要做什么&lt;/h2&gt;
&lt;p&gt;为图中的每个节点，确定其代表的操作类型以及正常运行所需的上下文信息。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LLM steps：当某一步骤需要理解、分析、生成文本或进行推理决策时&lt;/li&gt;
&lt;li&gt;Data steps：当某个步骤需要从外部来源获取信息时&lt;/li&gt;
&lt;li&gt;Action steps：当某个步骤需要执行外部操作时&lt;/li&gt;
&lt;li&gt;User input steps：当某个步骤需要人工介入时&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. 设计state&lt;/h2&gt;
&lt;p&gt;state是智能体中所有节点均可访问的共享存储器。可将其视作智能体在执行任务过程中，用于记录所有学习内容与决策信息的笔记本。这是非常重要的信息。&lt;/p&gt;
&lt;p&gt;我们要问自己两个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它是否需要在多个步骤间持续存在？如果是，就放入状态中。&lt;/li&gt;
&lt;li&gt;能否从其他数据推导得出？如果可以，在需要时计算即可，不必存入状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. 建立节点&lt;/h2&gt;
&lt;p&gt;现在我们将每个步骤实现为一个函数。LangGraph 中的节点只是一个 Python 函数，它接收当前状态并返回对状态的更新。&lt;/p&gt;
&lt;h3&gt;(1) 错误处理&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;错误类型&lt;/th&gt;
&lt;th&gt;由谁修复&lt;/th&gt;
&lt;th&gt;处理策略&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;瞬时错误（网络问题、限流等）&lt;/td&gt;
&lt;td&gt;系统自动处理&lt;/td&gt;
&lt;td&gt;重试策略（retry policy）&lt;/td&gt;
&lt;td&gt;这类失败通常是临时性的，重试后大概率恢复&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM 可恢复错误（工具调用失败、解析失败等）&lt;/td&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;把错误写入 state，再回到模型节点重试&lt;/td&gt;
&lt;td&gt;模型能够看到错误信息，并据此调整下一步做法&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户可修复错误（信息缺失、指令不清）&lt;/td&gt;
&lt;td&gt;人类用户&lt;/td&gt;
&lt;td&gt;使用 &lt;code&gt;interrupt()&lt;/code&gt; 暂停&lt;/td&gt;
&lt;td&gt;必须等待用户补充信息后才能继续&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;非预期错误&lt;/td&gt;
&lt;td&gt;开发者&lt;/td&gt;
&lt;td&gt;直接向上抛出异常&lt;/td&gt;
&lt;td&gt;未知问题，需要调试和排查根因&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(2) 实现节点&lt;/h3&gt;
&lt;p&gt;写node本身。&lt;/p&gt;
&lt;h2&gt;6. 建图&lt;/h2&gt;
&lt;p&gt;将节点连接成一个可运行的图结构。由于各个节点会自行处理路由决策，我们只需要几条核心的边即可。&lt;/p&gt;
&lt;h2&gt;7. 测试&lt;/h2&gt;
&lt;p&gt;测试、总结、升级。&lt;/p&gt;
</content:encoded></item><item><title>PyTorch CNN：从 LeNet 到经典卷积架构</title><link>https://owen571.top/posts/study/pytorch/05-pytorch-cnn-%E4%BB%8E-lenet-%E5%88%B0%E7%BB%8F%E5%85%B8%E5%8D%B7%E7%A7%AF%E6%9E%B6%E6%9E%84/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/05-pytorch-cnn-%E4%BB%8E-lenet-%E5%88%B0%E7%BB%8F%E5%85%B8%E5%8D%B7%E7%A7%AF%E6%9E%B6%E6%9E%84/</guid><description>从卷积和池化的基础直觉开始，先理解 LeNet，再顺着 GoogLeNet、ResNet 和 DenseNet 看卷积网络的发展脉络。</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇主要整理自 &lt;code&gt;liuer_pytorch/11-13.ipynb&lt;/code&gt;，以及 &lt;code&gt;pytorch_learning/pytorch_6.py&lt;/code&gt;。这组材料最适合的读法，不是逐个背模型名字，而是先把卷积网络的核心骨架理解清楚。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 为什么 CNN 适合图像&lt;/h2&gt;
&lt;p&gt;和全连接网络相比，卷积网络的关键优势在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;局部感受野：先看局部&lt;/li&gt;
&lt;li&gt;权重共享：同一个卷积核在不同位置复用&lt;/li&gt;
&lt;li&gt;参数更少：不会像全连接层那样一上来就爆炸&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;课程笔记里反复强调的一点是：&lt;br /&gt;
卷积层不是“更复杂的线性层”，而是一种刻意保留空间结构的线性变换。&lt;/p&gt;
&lt;h2&gt;2. LeNet 是理解 CNN 的最好起点&lt;/h2&gt;
&lt;p&gt;我自己在 &lt;code&gt;pytorch_learning/pytorch_6.py&lt;/code&gt; 里第一次完整写了一个 LeNet：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class LeNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 6, 5)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv2(x)), (2, 2))
        x = x.view(x.size(0), -1)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        return self.fc3(x)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码几乎把经典 CNN 的主干全露出来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;卷积&lt;/li&gt;
&lt;li&gt;激活&lt;/li&gt;
&lt;li&gt;池化&lt;/li&gt;
&lt;li&gt;展平&lt;/li&gt;
&lt;li&gt;全连接分类头&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LeNet 结构图我也一起保留到了博客目录里：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Flenet-architecture.CtEtSF1R.png&amp;amp;w=1133&amp;amp;h=1071&amp;amp;f=webp&quot; alt=&quot;LeNet 结构示意&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;3. 读 LeNet 时最该记住什么&lt;/h2&gt;
&lt;p&gt;我现在看 LeNet，最重要的不是记参数，而是记住这条流：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入图像
→ 卷积提取局部特征
→ 池化压缩空间尺寸
→ 再卷积、再池化
→ 展平后送入全连接层
→ 输出类别分数
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条流后来几乎影响了所有经典 CNN，只是中间模块变复杂了。&lt;/p&gt;
&lt;h2&gt;4. 从 LeNet 往后看：为什么网络越做越深&lt;/h2&gt;
&lt;p&gt;课程在 &lt;code&gt;Advanced CNN&lt;/code&gt; 那几节里，重点提到了三条线：&lt;/p&gt;
&lt;h3&gt;4.1 GoogLeNet / Inception&lt;/h3&gt;
&lt;p&gt;核心思路是：&lt;br /&gt;
不要只押注一种卷积核大小，而是在同一层里并行做多种卷积，再把结果拼起来。&lt;/p&gt;
&lt;p&gt;它解决的是“同一层特征尺度可能不一样”的问题。&lt;/p&gt;
&lt;h3&gt;4.2 ResNet&lt;/h3&gt;
&lt;p&gt;ResNet 最关键的点，是残差连接。&lt;/p&gt;
&lt;p&gt;它不是在说“网络一定要有捷径才厉害”，而是在解决一个更工程的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;网络越深，训练越难&lt;/li&gt;
&lt;li&gt;信息和梯度传得越来越差&lt;/li&gt;
&lt;li&gt;残差连接让模型更容易学到“至少别变坏”&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4.3 DenseNet&lt;/h3&gt;
&lt;p&gt;DenseNet 可以看成把“连接”这件事推得更极端：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前面层的特征不只残差相加&lt;/li&gt;
&lt;li&gt;而是更密集地往后传&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;课程里还顺手把它当成了一个“参考论文自己实现网络”的练习入口，这其实很对。&lt;/p&gt;
&lt;h2&gt;5. CNN 这一阶段该怎么学&lt;/h2&gt;
&lt;p&gt;我现在觉得最适合的顺序是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先把卷积、池化和 feature map 形状变化看懂&lt;/li&gt;
&lt;li&gt;再把 LeNet 写出来&lt;/li&gt;
&lt;li&gt;再去理解 GoogLeNet / ResNet / DenseNet 解决的具体问题&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不要一上来就被各种大模型名字压住。&lt;/p&gt;
&lt;h2&gt;6. 这一阶段该记住什么&lt;/h2&gt;
&lt;p&gt;如果只保留最少的几句话，我会记：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;CNN 的核心不是“更深”，而是“保留空间结构的局部特征提取”。&lt;/li&gt;
&lt;li&gt;LeNet 是最好的起点，因为它把卷积网络的主干完整展示出来了。&lt;/li&gt;
&lt;li&gt;GoogLeNet、ResNet、DenseNet 分别在解决不同的“怎么把 CNN 做得更强”的问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;到这里，PyTorch 已经不只是“会写训练循环”，而是开始真正进入深度学习模型结构本身了。&lt;/p&gt;
</content:encoded></item><item><title>RAG 文本分块：为什么切、怎么切、怎么权衡</title><link>https://owen571.top/posts/study/rag/03-rag-%E6%96%87%E6%9C%AC%E5%88%86%E5%9D%97%E7%AD%96%E7%95%A5/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/03-rag-%E6%96%87%E6%9C%AC%E5%88%86%E5%9D%97%E7%AD%96%E7%95%A5/</guid><description>理解分块在 RAG 中的地位，以及固定大小、递归分块、语义分块和结构化分块各自适合什么场景。</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;分块是 RAG 里最容易“看起来简单、实际上很关键”的步骤。这里把块大小、重叠与几种典型分块策略整理到一起，方便后面搭索引时直接回看。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 文本分块&lt;/h1&gt;
&lt;h2&gt;一、理解文本分块&lt;/h2&gt;
&lt;p&gt;文本分块（Text Chunking）是构建 RAG 流程的关键步骤。它的原理是将加载后的长篇文档，切分成更小、更易于处理的单元。这些被切分出的文本块，是后续向量检索和模型处理的基本单位。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-5.O4jQIdTR.png&amp;amp;w=1288&amp;amp;h=852&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;二、文本分块的重要性&lt;/h2&gt;
&lt;h3&gt;1. 上下文限制&lt;/h3&gt;
&lt;p&gt;将文本分块的首要原因，是为了适应 RAG 系统中两个核心组件的硬性限制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;嵌入模型 (Embedding Model): 负责将文本块转换为向量。这类模型有严格的输入长度上限。例如，许多常用的嵌入模型（如 bge-base-zh-v1.5）的上下文窗口为512个token。任何超出此限制的文本块在输入时都会被截断，导致信息丢失，生成的向量也无法完整代表原文的语义。因此，文本块的大小必须小于等于嵌入模型的上下文窗口。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;大语言模型 (LLM): 负责根据检索到的上下文生成答案。LLM同样有上下文窗口限制（尽管通常比嵌入模型大得多，从几千到上百万token不等）。检索到的所有文本块，连同用户问题和提示词，都必须能被放入这个窗口中。如果单个块过大，可能会导致只能容纳少数几个相关的块，限制了LLM回答问题时可参考的信息广度。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，分块是确保文本能够被两个模型完整、有效处理的基础。&lt;/p&gt;
&lt;h3&gt;2. 块大小的trade-off&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;块大小&lt;/th&gt;
&lt;th&gt;优势&lt;/th&gt;
&lt;th&gt;劣势&lt;/th&gt;
&lt;th&gt;对 RAG 的影响&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;大块&lt;/td&gt;
&lt;td&gt;上下文更完整，保留更多原文细节，适合需要整体语境的信息&lt;/td&gt;
&lt;td&gt;嵌入时信息被压缩得更严重，主题容易稀释，检索不够精准；生成时也容易出现“大海捞针”&lt;/td&gt;
&lt;td&gt;召回可能不稳定，噪声较多，回答容易遗漏关键点&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;中等块&lt;/td&gt;
&lt;td&gt;在上下文完整性和语义聚焦之间取得平衡&lt;/td&gt;
&lt;td&gt;仍可能混入少量无关信息，需要结合重叠策略优化&lt;/td&gt;
&lt;td&gt;通常是实践中最常用、效果最稳妥的选择&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;小块&lt;/td&gt;
&lt;td&gt;主题集中，语义清晰，检索匹配更精准，信噪比更高&lt;/td&gt;
&lt;td&gt;上下文可能不足，容易丢失前后关联，回答时可能缺背景&lt;/td&gt;
&lt;td&gt;召回更精确，但可能需要检索多个块拼接上下文&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;过小的块&lt;/td&gt;
&lt;td&gt;对单一知识点定位非常强&lt;/td&gt;
&lt;td&gt;语义过于碎片化，信息不完整，容易失去独立表达能力&lt;/td&gt;
&lt;td&gt;检索结果零散，增加后续整合和生成难度&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;三、基础分块策略&lt;/h2&gt;
&lt;p&gt;LangChain提供了丰富且易用的文本分割器 (Text Splitters)。&lt;/p&gt;
&lt;h3&gt;1. 固定大小分块 (CharacterTextSplitter)&lt;/h3&gt;
&lt;p&gt;这是最简单直接的分块方法。根据LangChain源码，这种方法的工作原理分为两个主要阶段：&lt;/p&gt;
&lt;p&gt;（1）按段落分割：CharacterTextSplitter 采用默认分隔符 &quot;\n\n&quot;，使用正则表达式将文本按段落进行分割，通过 _split_text_with_regex 函数处理。&lt;/p&gt;
&lt;p&gt;（2）智能合并：调用继承自父类的 _merge_splits 方法，将分割后的段落依次合并。该方法会监控累积长度，当超过 chunk_size 时形成新块，并通过重叠机制（chunk_overlap）保持上下文连续性，同时在必要时发出超长块的警告。&lt;/p&gt;
&lt;p&gt;需要注意，CharacterTextSplitter 实际实现的并非严格的固定大小分块。根据 _merge_splits 源码逻辑，这种方法会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优先保持段落完整性：只有当添加新段落会导致总长度超过 chunk_size 时，才会结束当前块&lt;/li&gt;
&lt;li&gt;处理超长段落：如果单个段落超过 chunk_size，系统会发出警告但仍将其作为完整块保留&lt;/li&gt;
&lt;li&gt;应用重叠机制：通过 chunk_overlap 参数在块之间保持内容重叠，确保上下文连续性&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，LangChain 的实现更准确地应该称为&quot;段落感知的自适应分块&quot;，块大小会根据段落边界动态调整。&lt;/p&gt;
&lt;p&gt;接下来我们配置一各固定大小分块器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader

loader = TextLoader(&quot;../../data/C2/txt/蜂医.txt&quot;)
docs = loader.load()

text_splitter = CharacterTextSplitter(
    chunk_size=200,    # 每个块的目标大小为100个字符
    chunk_overlap=10   # 每个块之间重叠10个字符，以缓解语义割裂
)

chunks = text_splitter.split_documents(docs)

print(f&quot;文本被切分为 {len(chunks)} 个块。\n&quot;)
print(&quot;--- 前5个块内容示例 ---&quot;)
for i, chunk in enumerate(chunks[:5]):
    print(&quot;=&quot; * 60)
    # chunk 是一个 Document 对象，需要访问它的 .page_content 属性来获取文本
    print(f&apos;块 {i+1} (长度: {len(chunk.page_content)}): &quot;{chunk.page_content}&quot;&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-6.BavrW09Y.png&amp;amp;w=1338&amp;amp;h=922&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这种方法的主要优势在于实现简单、处理速度快且计算开销小。劣势在于可能会在语义边界处切断文本，影响内容的完整性和连贯性。实际的固定大小分块实现（如LangChain的 CharacterTextSplitter）通常会结合分隔符来减少这种问题，在段落边界处优先切分，只有在必要时才会强制按大小切断。因此，这种方法在日志分析、数据预处理等场景中仍有其应用价值。&lt;/p&gt;
&lt;h3&gt;2. 递归字符分块 (RecursiveCharacterTextSplitter)&lt;/h3&gt;
&lt;p&gt;这种分块器通过分隔符层级递归处理，相对与固定大小分块，改善了超长文本的处理效果。&lt;/p&gt;
&lt;p&gt;算法流程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;寻找有效分隔符: 从分隔符列表中从前到后遍历，找到第一个在当前文本中存在的分隔符。如果都不存在，使用最后一个分隔符（通常是空字符串 &quot;&quot;）。&lt;/li&gt;
&lt;li&gt;切分与分类处理: 使用选定的分隔符切分文本，然后遍历所有片段：&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;如果片段不超过块大小: 暂存到 _good_splits 中，准备合并&lt;/li&gt;
&lt;li&gt;如果片段超过块大小:
&lt;ul&gt;
&lt;li&gt;首先，将暂存的合格片段通过 _merge_splits 合并成块&lt;/li&gt;
&lt;li&gt;然后，检查是否还有剩余分隔符：
&lt;ul&gt;
&lt;li&gt;有剩余分隔符: 递归调用 _split_text 继续分割&lt;/li&gt;
&lt;li&gt;无剩余分隔符: 直接保留为超长块&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;最终处理: 将剩余的暂存片段合并成最后的块&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;实现细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;批处理机制: 先收集所有合格片段（_good_splits），遇到超长片段时才触发合并操作。&lt;/li&gt;
&lt;li&gt;递归终止条件: 关键在于 if not new_separators 判断。当分隔符用尽时（new_separators 为空），停止递归，直接保留超长片段。确保算法不会无限递归。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;与固定大小分块的关键差异：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;固定大小分块遇到超长段落时只能发出警告并保留。&lt;/li&gt;
&lt;li&gt;递归分块会继续使用更细粒度的分隔符（句子→单词→字符）直到满足大小要求。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader

loader = TextLoader(&quot;../../data/C2/txt/蜂医.txt&quot;)
docs = loader.load()

text_splitter = RecursiveCharacterTextSplitter(
    separators=[&quot;\n\n&quot;, &quot;\n&quot;, &quot;。&quot;, &quot;，&quot;, &quot; &quot;, &quot;&quot;],  # 分隔符优先级
    chunk_size=200,
    chunk_overlap=10,
)

chunks = text_splitter.split_text(docs)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-7.iVV7hLwc.png&amp;amp;w=1338&amp;amp;h=1024&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;直觉上来看切的更碎了，总块数更多。这里默认的分隔符优先级也就是上文代码的&lt;code&gt;separators&lt;/code&gt;，可以自己调整，默认是&lt;code&gt;[&quot;\n\n&quot;, &quot;\n&quot;, &quot; &quot;, &quot;&quot;]&lt;/code&gt;，对于无词边界语言可以添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;separators=[
    &quot;\n\n&quot;, &quot;\n&quot;, &quot; &quot;,
    &quot;.&quot;, &quot;,&quot;, &quot;\u200b&quot;,      # 零宽空格(泰文、日文)
    &quot;\uff0c&quot;, &quot;\u3001&quot;,      # 全角逗号、表意逗号
    &quot;\uff0e&quot;, &quot;\u3002&quot;,      # 全角句号、表意句号
    &quot;&quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另外，还可以针对特定编程语言（如Python，Java等）使用预设的、更符合代码结构的分隔符。它们通常包含语言的顶级语法结构（如类、函数定义）和次级结构（如控制流语句），以实现更符合代码逻辑的分割。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 针对代码文档的优化分隔符
splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,  # 支持Python、Java、C++等
    chunk_size=500,
    chunk_overlap=50
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归字符分块的原理是采用一组有层次结构的分隔符（如段落、句子、单词）进行递归分割，旨在有效平衡语义完整性与块大小控制。在 RecursiveCharacterTextSplitter 的实现中，该分块器首先尝试使用最高优先级的分隔符（如段落标记）来切分文本。如果切分后的块仍然过大，会继续对这个大块应用下一优先级分隔符（如句号），如此循环往复，直到块满足大小限制。这种分层处理的机制，能够在尽可能保持高级语义结构完整性的同时，有效控制块大小。&lt;/p&gt;
&lt;h3&gt;3. 语义分块 (Semantic Chunking)&lt;/h3&gt;
&lt;p&gt;语义分块（Semantic Chunking）是一种更智能的方法，这种方法不依赖于固定的字符数或预设的分隔符，而是尝试根据文本的语义内涵来切分。其核心是：在语义主题发生显著变化的地方进行切分。这使得每个分块都具有高度的内部语义一致性。LangChain 提供了 &lt;code&gt;langchain_experimental.text_splitter.SemanticChunker&lt;/code&gt; 来实现这一功能。&lt;/p&gt;
&lt;h4&gt;(1) 实现原理&lt;/h4&gt;
&lt;p&gt;SemanticChunker 的工作流程可以概括为以下几个步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;句子分割 (Sentence Splitting)&lt;/strong&gt;：首先，使用标准的句子分割规则（例如，基于句号、问号、感叹号）将输入文本拆分成一个句子列表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;上下文感知嵌入 (Context-Aware Embedding)&lt;/strong&gt;：这是 SemanticChunker 的一个关键设计。该分块器不是对每个句子独立进行嵌入，而是通过 buffer_size 参数（默认为1）来捕捉上下文信息。对于列表中的每一个句子，这种方法会将其与前后各 buffer_size 个句子组合起来，然后对这个临时的、更长的组合文本进行嵌入。这样，每个句子最终得到的嵌入向量就融入了其上下文的语义。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;计算语义距离 (Distance Calculation)：计算每对相邻句子的嵌入向量之间的余弦距离。这个距离值量化了两个句子之间的语义差异——距离越大，表示语义关联越弱，跳跃越明显。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;识别断点 (Breakpoint Identification)&lt;/strong&gt;：SemanticChunker 会分析所有计算出的距离值，并根据一个统计方法（默认为 percentile）来确定一个动态阈值。例如，它可能会将所有距离中第95百分位的值作为切分阈值。所有距离大于此阈值的点，都被识别为语义上的“断点”。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;合并成块 (Merging into Chunks)&lt;/strong&gt;：最后，根据识别出的所有断点位置，将原始的句子序列进行切分，并将每个切分后的部分内的所有句子合并起来，形成一个最终的、语义连贯的文本块。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;(2) 断点识别方法 (breakpoint_threshold_type)&lt;/h4&gt;
&lt;p&gt;如何定义“显著的语义跳跃”是语义分块的关键。SemanticChunker 提供了几种基于统计的方法来识别断点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;percentile (百分位法 - 默认方法):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;逻辑: 计算所有相邻句子的语义差异值，并将这些差异值进行排序。当一个差异值超过某个百分位阈值时，就认为该差异值是一个断点。&lt;/li&gt;
&lt;li&gt;参数: breakpoint_threshold_amount (默认为 95)，表示使用第95个百分位作为阈值。这意味着，只有最显著的5%的语义差异点会被选为切分点。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;standard_deviation (标准差法):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;逻辑: 计算所有差异值的平均值和标准差。当一个差异值超过“平均值 + N * 标准差”时，被视为异常高的跳跃，即断点。&lt;/li&gt;
&lt;li&gt;参数: breakpoint_threshold_amount (默认为 3)，表示使用3倍标准差作为阈值。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;interquartile (四分位距法):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;逻辑: 使用统计学中的四分位距（IQR）来识别异常值。当一个差异值超过 Q3 + N * IQR 时，被视为断点。&lt;/li&gt;
&lt;li&gt;参数: breakpoint_threshold_amount (默认为 1.5)，表示使用1.5倍的IQR。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;gradient (梯度法):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;逻辑: 这是一种更复杂的方法。它首先计算差异值的变化率（梯度），然后对梯度应用百分位法。对于那些句子间语义联系紧密、差异值普遍较低的文本（如法律、医疗文档）特别有效，因为这种方法能更好地捕捉到语义变化的“拐点”。&lt;/li&gt;
&lt;li&gt;参数: breakpoint_threshold_amount (默认为 95)。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;稍微总结一下几个断点的优缺点，一般优先使用percentile就行了，默认切分效果不好时，再尝试gradient或更鲁棒的interquartile。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方法&lt;/th&gt;
&lt;th&gt;核心思路&lt;/th&gt;
&lt;th&gt;优点&lt;/th&gt;
&lt;th&gt;缺点&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;percentile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把句子间语义距离排序，超过某个百分位就切分&lt;/td&gt;
&lt;td&gt;简单直观，默认方法，通用性强&lt;/td&gt;
&lt;td&gt;对不同文档分布适应性一般，阈值偏经验化&lt;/td&gt;
&lt;td&gt;通用文本、入门默认选择&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;standard_deviation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;超过“均值 + N 倍标准差”视为断点&lt;/td&gt;
&lt;td&gt;能识别明显异常的语义跳跃&lt;/td&gt;
&lt;td&gt;对分布敏感，若数据波动不稳定，切分效果可能不稳&lt;/td&gt;
&lt;td&gt;语义跳跃较明显的普通文本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interquartile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用四分位距识别异常值，超过阈值就切分&lt;/td&gt;
&lt;td&gt;比标准差法更抗极端值干扰，鲁棒性更好&lt;/td&gt;
&lt;td&gt;理解门槛稍高，参数不如百分位法直观&lt;/td&gt;
&lt;td&gt;噪声较多、分布不均匀的文本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gradient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;关注语义距离变化率，在“变化拐点”处切分&lt;/td&gt;
&lt;td&gt;更擅长捕捉细微主题转折，对语义连续文本更敏感&lt;/td&gt;
&lt;td&gt;计算和理解都更复杂，调参成本更高&lt;/td&gt;
&lt;td&gt;法律、医疗、学术等语义连续但局部变化重要的文本&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre&gt;&lt;code&gt;import os
## os.environ[&quot;HF_ENDPOINT&quot;] = &quot;https://hf-mirror.com&quot;
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.document_loaders import TextLoader

embeddings = HuggingFaceEmbeddings(
    model_name=&quot;BAAI/bge-small-zh-v1.5&quot;,
    model_kwargs={&apos;device&apos;: &apos;cpu&apos;},
    encode_kwargs={&apos;normalize_embeddings&apos;: True}
)

# 初始化 SemanticChunker
text_splitter = SemanticChunker(
    embeddings,
    breakpoint_threshold_type=&quot;percentile&quot; # 断点识别方法
)

loader = TextLoader(&quot;../../data/C2/txt/蜂医.txt&quot;)
documents = loader.load()

docs = text_splitter.split_documents(documents)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;语义分块当然就都需要嵌入模型了，它是预训练之后的，知道将语义相近的句子嵌入后的高维向量拉进、不同的句子拉远。嵌入的过程大概就是分词、查表（查词表对应到id，然后查预训练好的嵌入表得初始向量）、加位置信息、Transformer编码、池化压缩成句子向量、归一。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    subgraph T[&quot;训练嵌入模型&quot;]
        direction TB
        T0[&quot;训练样本&amp;lt;br/&amp;gt;Query / Positive / Negative&quot;]
        T1[&quot;Tokenizer / 词表&amp;lt;br/&amp;gt;文本 -&amp;gt; token ids&quot;]
        T2[&quot;Embedding Table 查表&amp;lt;br/&amp;gt;token id -&amp;gt; 初始 token 向量&quot;]
        T3[&quot;位置编码&amp;lt;br/&amp;gt;加入位置信息&quot;]
        T4[&quot;Transformer Encoder&amp;lt;br/&amp;gt;让 token 融合上下文&quot;]
        T5[&quot;Pooling / Projection&amp;lt;br/&amp;gt;token 向量 -&amp;gt; 句向量&quot;]
        T6[&quot;得到句向量&amp;lt;br/&amp;gt;q / pos / neg&quot;]
        T7[&quot;计算相似度&amp;lt;br/&amp;gt;sim(q,pos), sim(q,neg)&quot;]
        T8[&quot;训练目标&amp;lt;br/&amp;gt;让 q 更接近 pos&amp;lt;br/&amp;gt;让 q 更远离 neg&quot;]
        T9[&quot;Loss&quot;]
        T10[&quot;反向传播&quot;]
        T11[&quot;更新参数&amp;lt;br/&amp;gt;Embedding Table&amp;lt;br/&amp;gt;Encoder&amp;lt;br/&amp;gt;Projection&quot;]

        T0 --&amp;gt; T1 --&amp;gt; T2 --&amp;gt; T3 --&amp;gt; T4 --&amp;gt; T5 --&amp;gt; T6 --&amp;gt; T7 --&amp;gt; T8 --&amp;gt; T9 --&amp;gt; T10 --&amp;gt; T11
    end

    M[&quot;训练好的嵌入模型参数&amp;lt;br/&amp;gt;Embedding Table + Encoder + Projection&quot;]

    T11 --&amp;gt; M

    subgraph U[&quot;使用嵌入模型&quot;]
        direction TB
        U0[&quot;新文本&amp;lt;br/&amp;gt;用户问题 / 文档块&quot;]
        U1[&quot;同一个 Tokenizer / 词表&quot;]
        U2[&quot;查训练好的 Embedding Table&amp;lt;br/&amp;gt;得到初始 token 向量&quot;]
        U3[&quot;位置编码&quot;]
        U4[&quot;经过训练好的 Encoder&amp;lt;br/&amp;gt;融合上下文&quot;]
        U5[&quot;Pooling / Projection&quot;]
        U6[&quot;最终句向量&quot;]
        U7[&quot;相似度计算 / 向量检索 / 入库&quot;]

        U0 --&amp;gt; U1 --&amp;gt; U2 --&amp;gt; U3 --&amp;gt; U4 --&amp;gt; U5 --&amp;gt; U6 --&amp;gt; U7
    end

    M -. 提供固定参数 .-&amp;gt; U2
    M -. 提供固定参数 .-&amp;gt; U4
    M -. 提供固定参数 .-&amp;gt; U5

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 基于文档结构的分块&lt;/h3&gt;
&lt;p&gt;对于具有明确结构标记的文档格式（如Markdown、HTML、LaTex），可以利用这些标记来实现更智能、更符合逻辑的分割。&lt;/p&gt;
&lt;p&gt;以 Markdown 结构分块为例
针对结构清晰的 Markdown 文档，利用其标题层级进行分块是一种高效且保留了丰富语义的方法。LangChain 提供了 MarkdownHeaderTextSplitter 来处理。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;实现原理: 该分块器的主要逻辑是“先按标题分组，再按需细分”。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;定义分割规则: 用户首先需要提供一个标题层级的映射关系，例如 [ (&quot;#&quot;, &quot;Header 1&quot;), (&quot;##&quot;, &quot;Header 2&quot;) ]，告诉分块器 # 是一级标题，## 是二级标题。&lt;/li&gt;
&lt;li&gt;内容聚合: 分块器会遍历整个文档，将每个标题下的所有内容（直到下一个同级或更高级别的标题出现前）聚合在一起。每个聚合后的内容块都会被赋予一个包含其完整标题路径的元数据。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;元数据注入的优势: 这是此方法的主要特点。例如，对于一篇关于机器学习的文章，某个段落可能位于“第三章：模型评估”下的“3.2节：评估指标”中。经过分割后，这个段落形成的文本块，其元数据就会是 {&quot;Header 1&quot;: &quot;第三章：模型评估&quot;, &quot;Header 2&quot;: &quot;3.2节：评估指标&quot;}。这种元数据为每个块提供了精确的“地址”，极大地增强了上下文的准确性，让大模型能更好地理解信息片段的来源和背景。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;局限性与组合使用: 单纯按标题分割可能会导致一个问题：某个章节下的内容可能非常长，远超模型能处理的上下文窗口。为了解决这个问题，MarkdownHeaderTextSplitter 可以与其它分块器（如 RecursiveCharacterTextSplitter）组合使用。具体流程是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一步，使用 MarkdownHeaderTextSplitter 将文档按标题分割成若干个大的、带有元数据的逻辑块。&lt;/li&gt;
&lt;li&gt;第二步，对这些逻辑块再应用 RecursiveCharacterTextSplitter，将其进一步切分为符合 chunk_size 要求的小块。由于这个过程是在第一步之后进行的，所有最终生成的小块都会继承来自第一步的标题元数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;RAG应用优势: 这种两阶段的分块方法，既保留了文档的宏观逻辑结构（通过元数据），又确保了每个块的大小适中，是处理结构化文档进行RAG的理想方案。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;四、其他开源框架中的分块策略&lt;/h2&gt;
&lt;p&gt;这后面简单瞅一下，Unstructrured前面也用过了。&lt;/p&gt;
&lt;h3&gt;1. Unstructured：基于文档元素的智能分块&lt;/h3&gt;
&lt;p&gt;Unstructured是一个强大的文档处理工具，同样提供了实用的分块功能。&lt;/p&gt;
&lt;p&gt;（1）分区 (Partitioning): 这是一个重要功能，负责将原始文档（如PDF、HTML）解析成一系列结构化的“元素”（Elements）。每个元素都带有语义标签，如 Title (标题)、NarrativeText (叙述文本)、ListItem (列表项) 等。这个过程本身就完成了对文档的深度理解和结构化。&lt;/p&gt;
&lt;p&gt;（2）分块 (Chunking): 该功能建立在分区的结果之上。分块功能不是对纯文本进行操作，而是将分区产生的“元素”列表作为输入，进行智能组合。Unstructured 提供了两种主要的分块方法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;basic: 这是默认方法。这种方法会连续地组合文档元素（如段落、列表项），直到达到 max_characters 上限，尽可能地填满每个块。如果单个元素超过上限，则会对其进行文本分割。&lt;/li&gt;
&lt;li&gt;by_title: 该方法在 basic 方法的基础上，增加了对“章节”的感知。该方法将 Title 元素视为一个新章节的开始，并强制在此处开始一个新的块，确保同一个块内不会包含来自不同章节的内容。这在处理报告、书籍等结构化文档时非常有用，效果类似于 LangChain 的 MarkdownHeaderTextSplitter，但适用范围更广。
Unstructured 允许将分块作为分区的一个参数在单次调用中完成，也支持在分区之后作为一个独立的步骤来执行分块。这种“先理解、后分割”的策略，使得 Unstructured 能在最大程度上保留文档的原始语义结构，特别是在处理版式复杂的文档时，优势尤为明显。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. LlamaIndex：面向节点的解析与转换&lt;/h3&gt;
&lt;p&gt;LlamaIndex 将数据处理流程抽象为对“节点（Node）”的操作。文档被加载后，首先会被解析成一系列的“节点”，分块只是节点转换（Transformation）中的一环。&lt;/p&gt;
&lt;p&gt;LlamaIndex 的分块体系有以下特点：&lt;/p&gt;
&lt;p&gt;（1）丰富的节点解析器 (Node Parser): LlamaIndex 提供了大量针对特定数据格式和方法的节点解析器，可以大致分为几类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;结构感知型: 如 MarkdownNodeParser, JSONNodeParser, CodeSplitter 等，能理解并根据源文件的结构（如Markdown标题、代码函数）进行切分。&lt;/li&gt;
&lt;li&gt;语义感知型:
&lt;ul&gt;
&lt;li&gt;SemanticSplitterNodeParser: 与 LangChain 的 SemanticChunker 类似，这种解析器使用嵌入模型来检测句子之间的语义“断点”，在语义连续性明显减弱的地方切开，从而让每个 chunk 内部尽量连贯。&lt;/li&gt;
&lt;li&gt;SentenceWindowNodeParser: 这是一种巧妙的方法。该方法将文档切分成单个的句子，但在每个句子节点（Node）的元数据中，会存储其前后相邻的N个句子（即“窗口”）。这使得在检索时，可以先用单个句子的嵌入进行精确匹配，然后将包含上下文“窗口”的完整文本送给LLM，极大地提升了上下文的质量。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;常规型: 如 TokenTextSplitter, SentenceSplitter 等，提供基于Token数量或句子边界的常规切分方法。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;（2）灵活的转换流水线: 用户可以构建一个灵活的流水线，例如先用 MarkdownNodeParser 按章节切分文档，再对每个章节节点应用 SentenceSplitter 进行更细粒度的句子级切分。每个节点都携带丰富的元数据，记录着其来源和上下文关系。&lt;/p&gt;
&lt;p&gt;（3）良好的互操作性: LlamaIndex 提供了 LangchainNodeParser，可以方便地将任何 LangChain 的 TextSplitter 封装成 LlamaIndex 的节点解析器，无缝集成到其处理流程中。&lt;/p&gt;
&lt;h3&gt;3. ChunkViz：简易的可视化分块工具&lt;/h3&gt;
&lt;p&gt;在本文开头部分展示的分块图就是通过 ChunkViz 生成的。可以将你的文档、分块配置作为输入，用不同的颜色块展示每个 chunk 的边界和重叠部分，方便快速理解分块逻辑。&lt;/p&gt;
</content:encoded></item><item><title>Actor-Critic 主线：优势函数、GAE、TRPO 与 PPO</title><link>https://owen571.top/posts/study/reinforce-learning/05-actor-critic-gae-trpo-ppo/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/05-actor-critic-gae-trpo-ppo/</guid><description>把优势函数、GAE、A2C/A3C、TRPO 与 PPO 放回一条 Actor-Critic 主线里理解。</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;现在, 我们解决了最后的难题, value-based 2 policy-based, 让智能体真正地去学习有概率的策略, 关于这一点的必要性已经在个笔记开篇举例说明. 需要注意的是, Policy-based算法并非承接了笔记1-3的进化过程, 而是从另一个道路开始, 所以笔记4中介绍的算法作为开山鼻祖但是效果很差、局限很多. 所以我们要吸收value-based中的优势, 这就是今天的主题Actor-Critic框架.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;前面从表格型方法一路推进到策略梯度之后，这一篇开始把 critic 重新请回来。也正是从这里起，强化学习真正进入现代工程里最常见的 Actor-Critic 主线。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. 把Q值请回来&lt;/h1&gt;
&lt;p&gt;REINFORCE算法实际上遇到了两个问题:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;方差太大. 因为是对每条轨迹的真实回报进行计算, 并不进行估算, 这是无偏性的必然结果. 在强化学习中, 偏差和方差是一个在权衡的过程.Reinforce没有偏差, 但是方差太大. 反观Q-learning和DQN都不是无偏的, 因为Q-learning是猜未来的Q值, 是不准确的; 而DQN本来就有偏差. 所以这两者方差都不会大.&lt;/li&gt;
&lt;li&gt;要等到一个episode之后才能更新. 这还要求任务是有限步的.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果回忆之前的内容, 我们就可以想到当时是从采样的MC方法, 进化到了单步更新的TD方法(默认TD(0)), 来达成不走完一条完整的轨迹也能更新Q的目的.  所以, 在策略梯度算法中, 我们同样可以借鉴类似的思路, 将回报替换成Q值来更新. 这样一来, 我们就可以用前面提到过的TD的方法来更新Q, 而不用等到整个trajectory采样完成.
$$
\nabla \overline{R}&lt;em&gt;{ \theta} \approx \frac{1}{N} \sum&lt;/em&gt;{n=1}^{N} \sum_{t=1}^{T_{n}}Q^{n} \left( s_{t}^{n},a_{t}^{n} \right) \nabla \log \pi_{ \theta} \left( a_{t}^{n}|s_{t}^{n} \right) \tag{1.1}
$$
我们会发现, 这样策略梯度算法的公式中, 引入回来了Q的部分, 也就是说将value-based的部分又带了回来, 并利用其优势为policy-based提供价值. &lt;strong&gt;这种将Policy-based和Value-based结合的算法, 我们就称之为Actor-Critic算法&lt;/strong&gt;. 其中, Critic网络负责得到Q值, 而Actor网络负责进行梯度策略的更新.&lt;/p&gt;
&lt;p&gt;需要注意的是, &lt;strong&gt;只要是将策略和价值相结合的方法, 我们就可以叫做是Actor- Critic算法&lt;/strong&gt;. 也就是说除了“三”下面的, &lt;strong&gt;DDPG、PPO、TRPO、GRPO等也都属于AC框架&lt;/strong&gt;, 只不过由于有些过于出名, 故单独拿出来介绍.&lt;/p&gt;
&lt;h1&gt;二. 优势函数&lt;/h1&gt;
&lt;h2&gt;1. 定义&lt;/h2&gt;
&lt;p&gt;笔记4中, 我们介绍策略梯度的实现技巧时, 将G减去了一个基线b. 作用已经在那里详细举例阐述, 并且那里的一个可能的b就是采样总奖励的均值. 我们在那里提了一嘴, 这 ( REINFORCE with baseline )就是优势函数的雏形. 广义的优势函数, 就是用于衡量“这个动作比平均好多少“的量. 但是由于时A2C中第一次明确使用了&lt;strong&gt;优势函数 (Advantage Function)&lt;/strong&gt; 的说法, 并且其中使用的基线为V(s), 所以狭义上来说, 优势函数的数学定义为:
$$
A(s,a)=Q(s,a)-V(s) \tag{2.1.1}
$$
优势函数无疑是AC框架的核心. 但是其中的“优势”在不同算法中有不同的表示. 或者说, 不同算法实际上就是对这个优势函数进行了不同的定义/优化.&lt;/p&gt;
&lt;h2&gt;2. Generalized Advantage Estimation (GAE)&lt;/h2&gt;
&lt;p&gt;上式中的Q和V都是老朋友了, 在之前的笔记中我们对如何估计他们做了详细的学习, 包括DP、MC、TD等. 早期算法如QAC、A2C、A3C都是通过简单的TD(0)或者n步回报来估计优势函数, 就如同之前的value-based一样.&lt;/p&gt;
&lt;p&gt;GAE是策略梯度算法中用于估计优势函数的一种高级技巧, 它提供了一种实用的估计方法来计算优势函数. 我们可以在这里将估计优势函数的方法进行对比总结.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;估计方法&lt;/th&gt;
&lt;th&gt;公式&lt;/th&gt;
&lt;th&gt;特点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;蒙特卡洛 (MC)&lt;/td&gt;
&lt;td&gt;$A_t = \sum_{k=0}^∞ γ^k r_{t+k} - V(s_t)$&lt;/td&gt;
&lt;td&gt;无偏估计, 但是高方差&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TD(0)&lt;/td&gt;
&lt;td&gt;$A_t = r_t + γV(s_{t+1}) - V(s_t)$&lt;/td&gt;
&lt;td&gt;低方差, 但是有偏估计&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TD(n)&lt;/td&gt;
&lt;td&gt;$A_t = \sum_{k=0}^{n-1} γ^k r_{t+k} + γ^n V(s_{t+n}) - V(s_t)$&lt;/td&gt;
&lt;td&gt;折中方案&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GAE&lt;/td&gt;
&lt;td&gt;$A_t^{GAE} = \sum_{l=0}^∞ (γλ)^l δ_{t+l}$&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;自适应均衡&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;怎么得到GAE, 我们现在将经典TD算法的式子展开, 来表示t时刻往前看k个step的情况下, 对当前形式的估计:&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A^{k}(t)=r_{t}+\gamma r_{t+1}+\gamma^{2}r_{t+2}+\cdot\cdot\cdot+\gamma^{k-1}r_ {t+k-1}+\gamma^{k}V(s_{t+k})-V(s_{t}) \tag{2.2.1}&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;凡事都有两面性, 对于$A^{k}(t)$ 来说, k越大意味着观测值越多, 估计值越少, 那么偏差越小, 方差越大; 反之, 观测值越少, 估计值越多, 偏差越大, 方差越小. 所以为了trade-off偏差和方差, GAE考虑对原始的$A^{k}(t)$ 进行修改, 与估计奖励时的思想类似, 我们在估计优势函数时, 也综合考虑不同step的估计值, 于是可以对不同的$A^{k}(t)$ 加权求和:&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;\begin{split} A_{t}^{\textit{GAE}&lt;em&gt;{1}}&amp;amp;=A&lt;/em&gt;{t}^{1}+ \lambda A_{t}^{2}+\lambda^{2}A_{t}^{3}+\cdot\cdot\cdot\ &amp;amp;=\delta_{t}+\lambda(\delta_{t}+\gamma\delta_{t+1})+\lambda^{2}( \delta_{t}+\gamma\delta_{t+1}+\gamma^{2}\delta_{t+2})+\cdot\cdot\cdot\ &amp;amp;=\delta_{t}(1+\lambda+\lambda^{2}+\cdot\cdot\cdot)+\gamma\delta &lt;em&gt;{t+1}(\lambda+\lambda^{2}+\cdot\cdot\cdot)+\gamma^{2}\delta&lt;/em&gt;{t+2}(\lambda^{2 }+\cdot\cdot\cdot)+\cdot\cdot\cdot\end{split} \tag{2.2.2}&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;我们将$\delta$ 称为TD残差. 其中$\delta_t$ 是TD(0)算法中的$A_t$ , 加入的参数$\lambda \in [0,1]$, 观察上式, 我们就发现可以通过调节$\lambda$ 的值来进行tradeoff, 当其为0时, 就变成了简单的TD(0), 而当其为1时, 就变成了蒙特卡洛采样. 表示先假设不能取1. 然后我们进一步推导, 根据等比数列求和公式:&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A_{t}^{GAE_{1}}=\delta_{t}(\frac{1-\lambda^{k}}{1-\lambda})+\gamma\delta_{t+1 }(\frac{\lambda(1-\lambda^{k - 1})}{1-\lambda})+\gamma^{2}\delta_{t+2}( \frac{\lambda^{2}(1-\lambda^{k - 2})}{1-\lambda})+\cdots \tag{2.2.3}&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;由于$1-\lambda$ 是一个常数, 可以两边同乘, 而当k趋向于无穷, 上面的$\lambda^{k-n}$趋向于0. 再直接用新的A替换, 得到:&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A_{t}^{GAE}=\delta_{t}+\gamma\lambda\delta_{t+1}+\gamma^{2}\lambda^{2}\delta_ {t+2}+\cdot\cdot\cdot=\sum_{k=0}^{ \infty} \left( \gamma \lambda \right)^{k} \delta_{t+k} \tag{2.2.4}&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$$&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上式就是GAE算法的核心公式. (乍一看没有baseline, 其实TD残差$\delta$ 中就包含了baseline了).&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h1&gt;三. AC经典算法&lt;/h1&gt;
&lt;h2&gt;1. QAC (Q-based Actor-Critic)&lt;/h2&gt;
&lt;p&gt;Actor是演员, 负责选择动作, 是一个以$\theta$ 参数化的策略函数$\pi_\theta(a|s)$ ; Critic是评论家, 用来评价动作的好坏, 用评估结果知道actor改进策略. 如果Critic用Q值来计算误差, 那么就称为&lt;strong&gt;基于Q值的Actor-Critic算法, QAC&lt;/strong&gt;. 如果是基于$V(s&apos;)$ 值来计算的话, 用$R+\gamma V$ 代替Q值, 成为&lt;strong&gt;基于优势函数的Actor-Critic算法, 也叫Advantage-Actor-Critic算法, 也即A2C&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;(1) 优化思想&lt;/h3&gt;
&lt;p&gt;QAC的优化是针对与REINFORCE而言的, 最大的好处就是引入了基线. Q值的求取, 我们采用学习笔记(二)中的时序差分学习TD算法. Critic是一个价值网络, 通过学习Q值, 逼近真实的$Q_\pi(s,a)$; Actor是策略更新.&lt;/p&gt;
&lt;p&gt;Critic的更新是Sarsa算法:
$$
Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha [r_{t+1} + \gamma Q(s_{t+1},a_{t+1}) - Q(s_t,a_t)] \tag{3.1.1}
$$&lt;/p&gt;
&lt;p&gt;Actor的更新则是策略梯度, 其实就是用Q值来作为梯度策略的奖励R:
$$
\theta \leftarrow \theta+\alpha \nabla_\theta log \pi_\theta(a|s) \cdot Q(s,a) \tag{3.1.2}
$$&lt;/p&gt;
&lt;h3&gt;(2) 优势函数&lt;/h3&gt;
&lt;p&gt;基本QAC中, 实质上没有显式的优势函数, 它直接使用Q值作为了基线. 它实际上就是式子1.1, 朴素的将Q函数引入了进来而已.&lt;/p&gt;
&lt;h3&gt;(3) 流程&lt;/h3&gt;
&lt;p&gt;QAC的流程如下: Actor观察当前状态, 按照当前策略随机执行一个动作, Agent从环境得到即时反馈, Actor按照当前的策略$\theta$ (一个策略网络) 随机一个动作, 但是只选不行动. 此时, Critic用当前价值网络计算当前状态动作对的估计价值$Q(s_t,a_t)$ 和下一个动作对的预估价值$Q(s_{t+1},a_{t+1})$ . 最后, 计算TD Target和TD Error. 然后用TD梯度下降更新参数.&lt;/p&gt;
&lt;h2&gt;2. Advantage Actor-Critic算法 (A2C)&lt;/h2&gt;
&lt;h3&gt;(1) 优化思想&lt;/h3&gt;
&lt;p&gt;A2C与QAC相比, 首次显式计算了优势函数. 使用状态价值函数V作为Critic.&lt;/p&gt;
&lt;p&gt;Critic是V函数的更新:
$$
V(s_t) \leftarrow V(s_t) + \alpha (r_{t+1} + \gamma V(s_{t+1}) - V(s_t)) \tag{3.2.1}
$$
还记得吗, 这个式子是在笔记(二)引入TD的时候的式子, 实际上这里的TD Target就是Q的贝尔曼公式(可以回看笔记(一)).&lt;/p&gt;
&lt;p&gt;Actor的更新则是使用了优势函数作为R的策略梯度:
$$
\theta \leftarrow \theta+\alpha \nabla_\theta log \pi_\theta(a|s) \cdot A(s,a) \tag{3.2.2}
$$&lt;/p&gt;
&lt;h3&gt;(2) 优势函数&lt;/h3&gt;
&lt;p&gt;利用前面的知识, 我们把Q值写为TD残差的形式, 从而得到:
$$
A(s,a)=r_{t+1} + \gamma V(s_{t+1})-V(s_t) \tag{3.2.3}
$$
A2C构造&lt;strong&gt;优势函数&lt;/strong&gt;$A(s,a)=Q(s,a)-V(s)$, 状态s下选动作a, 比状态s的平均动作价值好多少. Critic的目标是让自己的预测最准确, 通过不断修正自己的估计, 来让$A(s,a)$ 最小化, 这意味着$V(s_t)$ 可以更精确表示从状态$s_t$ 出发的实际回报. 批评家的评价越准确, 演员的动作调整才越正确. (如果你详细研究了笔记1, 那里其实说明了最好的Q就等于V, 显然我们需要逼近最好的Q的策略, 让优势函数即亮着的差值最小).&lt;/p&gt;
&lt;p&gt;说到这里, 显然这个优势函数可以看作是网络的Loss (实际上就是), 于是我们借鉴深度学习中的更新方法, 把这个Loss求一个平方损失误差, 来更新Critic网络. 我们把Critic网络的参数写做$\omega$ , 就有如下平方损失误差:
$$
L(\omega)=\frac{1}{2}\left(r_{t}+\gamma V_{\omega}\left(s_{t+1}\right)-V_{\omega}\left(s_{t}\right)\right)^{2} \tag{3.2.4}
$$
对损失函数求导有不带系数的简洁形式:
$$
\nabla_{\omega} L(\omega)=-\left(r_{t}+\gamma V_{\omega}\left(s_{t+1}\right)-V_{\omega}\left(s_{t}\right)\right) \nabla_{\omega} V_{\omega}\left(s_{t}\right) \tag{3.2.5}
$$
使用梯度下降来更新参数即可.&lt;/p&gt;
&lt;p&gt;而且, 这个优势函数A值同时也会去用上述的3.2.2式子去更新策略$\theta$ 即Actor网络. 所以我们说, 在每个mini-batch中, 我们同时:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用优势函数A通过策略梯度更新Actor网络&lt;/li&gt;
&lt;li&gt;用A相关的目标值通过MSE更新Critic网络&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;这样, Actor和Critic就可以协同进化. Actor需要Critic评估动作好坏, 而Critic需要Actor策略来准确估计价值(别忘了, Q函数是隐含策略的). 我们回想Q-learning, 其实是除了将AC算法看成是Policy-based引入了Q值, 也可以看成是&lt;strong&gt;Q-learning算法的贪心策略被替换成了Actor部分&lt;/strong&gt;, 一个新的动作选择策略, 并且是概率分布形式的. 这是分别从Value和Policy出发, 得到AC算法的视角, 殊途同归.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;3. Asynchronous Methods for Deep Reinforcement Learning算法 (A3C)&lt;/h2&gt;
&lt;p&gt;如名称所示, 这是一种异步强化的学习算法, 这是一种非常有效的算法, 在围棋、星际争霸等复杂任务上都取得了很好的效果. 不过有一点要说的, A3C其实比A2C出现的要早, A2C可以看作A2C的同步简化版.&lt;/p&gt;
&lt;p&gt;A3C的最大优点就是可以加快强化学习的速度, 它同时使用多个进程 (worker), 这些进程会把所有的经验集合在一起, 所以对硬件也是有需求的.&lt;/p&gt;
&lt;p&gt;A3C一开始有一个全局网络 (global network). 全局网络包含策略网络和价值网络, 它们在前几个层会绑定(tie)在一起. 每个进程在工作前都把全局网络的参数复制过来, 接下来与环境交互计算梯度, 再梯度去更新全局网络的参数.&lt;/p&gt;
&lt;p&gt;A3C采用了平行探索的方法, 所有演员都是平行跑的, 每个演员各做各的, 当然传回去的参数可能发生覆盖, 但是没关系, 由于每次工作时复制, 所以总会以最新的参数去交互.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251108101255.DzNJHdEK.png&amp;amp;w=996&amp;amp;h=936&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;虽然速度上有提升, 但是多个worker同时更新网络, 可能会导致训练不稳定, 重新性差. 与之相比, A2C不仅简洁, 而且某些任务上性能会更好, 更重要的是可重新训练方法, 在对稳定性要求极高的RLHF中, A2C的设计更好.&lt;/p&gt;
&lt;h1&gt;四. Trust Region Policy Optimization  (TRPO)算法&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;注: TRPO背后的数学原理比较复杂, 因此进行推导的过程繁琐, 引入的概念也很多, 请更注重了解其中的思想. TRPO也是PPO算法的基础, 但是PPO算法已经在效率上全面打败TRPO. 以下推导过程的公式完全可以略过, 仅做参考.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;相比于深度学习, 我们面对的重大问题之一, 就是在面对复杂的未知函数形状的&quot;山&quot;,  用梯度上升很难决定往哪走走多少, 也无法确保是凸函数. 而TRPO则设定了&lt;strong&gt;置信域&lt;/strong&gt; 在旧策略的领域, 在这之中在优化策略. 相当于在目前未知&quot;山&quot;的形状, 每次画一个小圆圈, 在安全范围内大步走.&lt;/p&gt;
&lt;p&gt;我们用&lt;strong&gt;KL散度&lt;/strong&gt;来衡量新旧策略的远近, 并将其限制在阈值内. 并且, 从理论上证明了这是在单调改进.&lt;/p&gt;
&lt;p&gt;策略$\pi$ 的期望回报:&lt;br /&gt;
$$
\eta(\pi)=\mathbb{E}&lt;em&gt;{s_0,a_0,\ldots}\left[\sum&lt;/em&gt;{t=0}^\infty\gamma^tr(s_t)\right]\tag{4.1}
$$
而2002年, Kakade &amp;amp; Langford等人得出了这样的结论: 新策略的期望回报 = 旧策略的期望回报 + 新策略在旧策略优势函数上的累计期望:
$$
\eta(\tilde{\pi})=\eta(\pi)+\mathbb{E}&lt;em&gt;{s_0,a_0,\cdots\sim\tilde{\pi}}\left[\sum&lt;/em&gt;{t=0}^\infty\gamma^tA_\pi(s_t,a_t)\right] \tag{4.2}
$$
我们后面的推导, 将结合7.2式进行. 首先根据期望的线性可拆, 我可以将求和符号提出来:
$$
\mathbb{E}&lt;em&gt;{s&lt;/em&gt;{0},a_{0},\cdots\sim\tilde{\pi}}\left[\sum_{t=0}^{\infty}\gamma^{t}A_{\pi}(s_{t},a_{t})\right]=\sum_{t=0}^{\infty}\gamma^{t}\cdot\mathbb{E}&lt;em&gt;{s&lt;/em&gt;{0},a_{0},\cdots\sim\tilde{\pi}}\left[A_{\pi}(s_{t},a_{t})\right] \tag{4.3}
$$
接下来, 我们引入&lt;strong&gt;折扣访问频率&lt;/strong&gt; (&lt;strong&gt;状态占据度量&lt;/strong&gt;) , 来表示状态s在策略$\pi$ 之后的长期权重, 经过t步之后处于状态s的折扣概率之和:
$$
\begin{aligned}&amp;amp;\rho_{\pi}(s)\&lt;/p&gt;
&lt;p&gt;&amp;amp; {=}P(s_{0}=s){+}\gamma P(s_{1}=s){+}\gamma^{2}P(s_{2}=s){+}\ldots \&lt;/p&gt;
&lt;p&gt;&amp;amp; =\sum_{t=0}^\infty\gamma^t\cdot\mathbb{P}&lt;em&gt;{s_0\sim d,\tilde{\pi}}\left[s_t=s\right] \end{aligned}\tag{4.4}
$$
接下来, 我们把$A&lt;/em&gt;\pi(s_t,a_t)$ 视作随机变量, 指的就是在$s_t$ 这个状态下选择$a_t$ 动作的Q值, 比上选择a状态的平均Q值多出来的部分. 换言之, 就是跟AC算法中一样的&lt;strong&gt;优势函数&lt;/strong&gt;. 它的期望可以进行分解:&lt;/p&gt;
&lt;p&gt;$$
\begin{aligned}&amp;amp;\mathbb{E}&lt;em&gt;{s_0, a_0, \cdots \sim \tilde{\pi}} \left[ A&lt;/em&gt;{\pi}(s_t, a_t) \right] \&lt;/p&gt;
&lt;p&gt;&amp;amp;= \sum_s \sum_a A_{\pi}(s, a) \cdot \mathbb{P}&lt;em&gt;{s_0, a_0, \cdots \sim \tilde{\pi}} \left( s_t = s, a_t = a \right)\end{aligned} \tag{4.5}
$$
将4.5求和的P写为条件概率的形式:
$$
\mathbb{P}\left[s&lt;/em&gt;{t}=s,,a_{t}=a\right]=\mathbb{P}\left[s_{t}=s\right]\cdot\mathbb{P}\left[a_{t}=a\mid s_{t}=s\right]\tag{4.6}
$$
由于是马尔可夫链, 动作选择仅依赖于当前状态, 所以有:&lt;br /&gt;
$$
\mathbb{P}[a_t=a \mid s_t=s]=\tilde{\pi}(a \mid s)\tag{4.7}
$$
4.7代回4.6, 再代回4.5得到优势函数的期望实际为:
$$
\begin{aligned}&lt;/p&gt;
&lt;p&gt;&amp;amp;\mathbb{E}&lt;em&gt;{s&lt;/em&gt;{0},a_{0},\ldots\sim\tilde{\pi}}\left[A_{\pi}(s_{t},a_{t})\right]\&lt;/p&gt;
&lt;p&gt;&amp;amp;=\sum_{s}\sum_{a}A_{\pi}(s,a)\cdot\mathbb{P}&lt;em&gt;{s&lt;/em&gt;{0},a_{0},\ldots\sim\tilde{\pi}}(s_{t}=s)\cdot\tilde{\pi}(a\mid s)\end{aligned}\tag{4.8}
$$
再代回4.2后半, 我们可得到新形式, 整理后发现出现了前面4.4引入的状态占据量:&lt;/p&gt;
&lt;p&gt;$$
\begin{aligned}&amp;amp;\mathbb{E}&lt;em&gt;{s_0,a_0,\cdots\sim\tilde{\pi}}\begin{bmatrix}&amp;amp;\gamma^tA&lt;/em&gt;\pi(s_t,a_t)\end{bmatrix}\&lt;/p&gt;
&lt;p&gt;&amp;amp;=\gamma^t\cdot(\sum_s\sum_aA_\pi(s,a)\cdot\mathbb{P}(s_t=s)\cdot\tilde{\pi}(a\mid s))\&lt;/p&gt;
&lt;p&gt;&amp;amp;=\sum_{s}\sum_{a}\left(\sum_{t=0}^{\infty}\gamma^{t}\cdot\mathbb{P}(s_{t}=s)\right)\cdot\widetilde{\pi}(a\mid s)\cdot A_{\pi}(s,a)\&lt;/p&gt;
&lt;p&gt;&amp;amp;=\sum_s\rho_{\tilde{\pi}}(s)\sum_a\tilde{\pi}(a|s)A_\pi(s,a)\end{aligned}&lt;/p&gt;
&lt;p&gt;\tag{4.9}
$$
将4.9代入4.2:
$$
\eta(\tilde{\pi})=\eta(\pi)+\sum_s\rho_{\tilde{\pi}}(s)\sum_a\tilde{\pi}(a|s)A_\pi(s,a)\tag{4.10}
$$
这时我们发现, 要想求出这个值, 是需要新策略$\tilde{\pi}$ 的状态分布情况. 而这个值目前是得不到的. 所以我们引入&lt;strong&gt;代理函数&lt;/strong&gt;, 用原策略来近似:
$$
L_\pi(\tilde{\pi})=\eta(\pi)+\sum_s\rho_\pi(s)\sum_a\tilde{\pi}(a|s)A_\pi(s,a)\tag{4.11}
$$
我们发现, 在旧策略$\pi$ 处,$L_\pi$ 和$\eta$ 的梯度相同, 所以在旧策略附近优化$L_\pi$ 就近似于优化$\eta$ .&lt;/p&gt;
&lt;p&gt;接下来, 我们还要引入&lt;strong&gt;KL散度&lt;/strong&gt;来衡量两个概率分布的远近, 用来表示用q来近似p的时候的信息损失:
$$
D_{KL}(P||Q)=E_p\left[\log\frac{P(x)}{Q(x)}\right]=\Sigma_ip_i\log\frac{p_i}{q_i}\tag{4.12}
$$&lt;/p&gt;
&lt;p&gt;另外还有一种&lt;strong&gt;总变差散度(TV散度)&lt;/strong&gt; 也可以衡量:
$$
D_{\mathrm{TV}}(P||Q) = \frac{1}{2} \Sigma_i |p_i - q_i|\tag{4.13}
$$
而 &lt;strong&gt;Pinsker不等式&lt;/strong&gt; 就把两个散度联系到了一起:
$$
|Q-P|&lt;em&gt;{\text{TV}} \leq \sqrt{\frac{1}{2}D&lt;/em&gt;{\text{KL}}(Q|P)} \tag{4.14}
$$
简化形式:
$$
D_{TV}(p||q)^2\leq D_{KL}(p||q)\tag{4.15}
$$
TPRO论文, 证明了$L_\pi$ 和$\eta$ 的误差下界:
$$
\eta(\pi_{new})\geq L_{\pi_{old}}(\pi_{new})-\frac{4\epsilon\gamma}{(1-\gamma)^2}\alpha^2 \tag{4.16}
$$
其中$\alpha$ 是新旧两个策略在所有状态下的最大TV散度, 且$\epsilon=\max_{s.a}|A_{\pi}(s,a)|$ , 我们把$\frac{4\epsilon\gamma}{(1-\gamma)^2}$ 当作常数C处理, 结合4.14不等式, 就可以导出:
$$
\eta(\tilde{\pi})\geq L_\pi(\tilde{\pi})-C\cdot D_{KL}^{\max}(\pi,\tilde{\pi})\tag{4.17}
$$
这是一个很好的式子, 观察式子, 不等式右边可以成为新策略的性能下界, 即:
$$
M(\pi)=L_\pi(\tilde{\pi})-C\cdot D_{KL}^{\max}(\pi,\tilde{\pi})\tag{4.18}
$$
换言之, 只要提升/最大化$M(\pi)$ , 就能保证$\eta$ 的性能单调上升. 这演变为了重要的&lt;strong&gt;MM&lt;/strong&gt; 算法, 这是一种迭代的方法, 它利用函数的凸性来寻找它们的最大值或最小值. 本问题是目标函数最大化问题, 所以MM的具体表现为 &lt;strong&gt;Minorize-Maximization&lt;/strong&gt; 算法: 每次迭代找到原非凸目标函数的一个下界函数 , 求下界函数的最大值.
$$
\underset{\theta}{\operatorname*{\mathrm{maximize}}}\left[L_{\theta_{old}}(\theta)-C\cdot D_{KL}^{\max}(\theta_{old},\theta)\right]\tag{4.19}
$$
继续观察4.18, C因为分母带有1-折扣因子的平方, 当折扣因子取的很大时, 就会让C很大. 这就可以看出来, C其实是一个惩罚项的权重, 换而言之: 新旧策略的分布离得远和折扣因子偏大都会惩罚.&lt;/p&gt;
&lt;p&gt;但是这里有个问题, $\gamma$ 如果取大一点,  C就会变得超级大, 给策略距离超级加倍, 导致不敢更新策略了.&lt;/p&gt;
&lt;p&gt;为了解决这个问题, 我们引入&lt;strong&gt;置信域&lt;/strong&gt;, 我们把7.19中的这个惩罚项改成置信域, 变成带约束条件的最大化问题, 4.19变成了:&lt;/p&gt;
&lt;p&gt;$$
\begin{aligned} &amp;amp;\underset{\theta}{maximize}\qquad L_{\theta_{old}}\&lt;/p&gt;
&lt;p&gt;&amp;amp; subject\quad to \qquad \overline {D}_{KL}^{\rho _{\theta _{old}}}( \theta _{old}, \theta ) \leq \delta\end{aligned}\tag{4.20}
$$&lt;/p&gt;
&lt;p&gt;其中:
$$
L_{\theta_{\mathrm{old}}}(\theta) = \mathbb{E}&lt;em&gt;{s \sim \rho&lt;/em&gt;{\theta_{\mathrm{old}}}, a \sim \pi_{\theta}} \left[ A_{\pi_{\theta_{\mathrm{old}}}}(s, a) \right] \tag{4.21}
$$&lt;/p&gt;
&lt;p&gt;我们来看一下$\overline {D}&lt;em&gt;{KL}^{\rho &lt;em&gt;{\theta &lt;em&gt;{old}}}( \theta &lt;em&gt;{old}, \theta )$ 这个式子, 这其实是把最大KL散度变成了平均KL散度. 这是因为, 如果是最大KL散度的话, 我们就要要求所有状态的KL散度都小于某个值, 这是难以实现的. 而平均KL散度只约束旧策略访问到的平均KL散度:
$$
\overline{D}&lt;/em&gt;{\mathrm{KL}}^\rho(\theta_1,\theta_2):=\mathbb{E}&lt;/em&gt;{s\sim\rho}\left[D&lt;/em&gt;{\mathrm{KL}}(\pi&lt;/em&gt;{\theta_1}(\cdot|s)\parallel\pi_{\theta_2}(\cdot|s))\right] \tag{4.22}
$$
这里又要引入强化学习的一个重要概念: &lt;strong&gt;重要性采样&lt;/strong&gt;. 我们要计算$E_{X\sim q}[f(X)]$, 但是从q上采样, 可能并非是最优的. 我们要改成p上采样的话, 可以推到出如下式子:
$$
\begin{aligned}\mathbb{E}&lt;em&gt;{X\sim q}[f(X)]&amp;amp;=\int f(x)q(x)dx\&amp;amp;=\int f(x)\cdot\frac{q(x)}{p(x)}\cdot p(x)dx\&amp;amp;=\mathbb{E}&lt;/em&gt;{X\sim p}\left[f(X)\cdot\frac{q(x)}{p(x)}\right]\end{aligned}\tag{4.23}
$$&lt;/p&gt;
&lt;p&gt;其中$\frac{q(x)}{p(x)}$ 被称为&lt;strong&gt;重要性权重&lt;/strong&gt;, 代表的是从$p(x)$ 采样的样本修正到$q(x)$ 分布下的期望估计.&lt;/p&gt;
&lt;p&gt;观察7.21, 我们引入的原因是, 虽然我们已经通过代理函数, 让状态从旧策略进行采样, 但是a依然是在新策略$\pi_{\theta}$ 下进行采样, 但是我们只能从旧策略中采样动作. 因此用重要性采样思想:
$$
\mathbb{E}&lt;em&gt;{a\sim\pi&lt;/em&gt;{\theta}}[f(a)]=\mathbb{E}&lt;em&gt;{a\sim\pi&lt;/em&gt;{\theta_{\text{old}}}}\left[f(a)\cdot\frac{\pi_{\theta}(a|s)}{\pi_{\theta_{\text{old}}}(a|s)}\right]\tag{4.24}
$$
即:
$$
L_{\theta_{\mathrm{old}}}(\theta) = \mathbb{E}&lt;em&gt;{s \sim \rho&lt;/em&gt;{\theta_{\mathrm{old}}}, a \sim \pi_{\theta_{\mathrm{old}}}} \left[\frac{\pi_{\theta}(a|s)}{\pi_{\theta_{\mathrm{old}}}(a|s)} \cdot A_{\pi_{\theta_{\mathrm{old}}}}(s, a)\right]\tag{4.25}
$$
最后, TRPO还在$\theta = \theta_{old}$ 附近都做了近似:  $L_{\theta_{\mathrm{old}}}(\theta)$ 在$\theta = \theta_{old}$ 做一阶泰勒展开; 约束条件(平均KL散度)做二阶泰勒展开:
$$
L_{\theta_{old}}(\theta) \approx L_{\theta_{old}}(\theta_{old}) + \nabla_{\theta}L_{\theta_{old}}(\theta_{old}) \cdot (\theta - \theta_{old})\tag{4.26}
$$
$$
\overline{D}&lt;em&gt;{KL}^{\rho&lt;/em&gt;{\theta_{old}}}(\theta_{old},\theta)\approx\frac12\Delta\theta^TA\Delta\theta \tag{4.27}
$$
其中$\Delta\theta$ 是参数的更新量, A为平均KL散度在$\theta_{old}$ 处的&lt;strong&gt;Hessian矩阵&lt;/strong&gt; (海森矩阵是多元二阶偏导数构成的对称矩阵), 即:
$$
A=\frac{\partial}{\partial\theta_{i}}\frac{\partial}{\partial\theta_{j}}\mathbb{E}&lt;em&gt;{s\sim\rho&lt;/em&gt;{\pi}}\left[D_{\mathrm{KL}}\left(\pi(\cdot|s,\theta_{\mathrm{old}})\parallel\pi(\cdot|s,\theta)\right)\right]\bigg|&lt;em&gt;{\theta=\theta&lt;/em&gt;{\mathrm{old}}}\tag{4.28}
$$
最终, 问题转化成了:
$$
\begin{aligned}&amp;amp;\max~g^T(\theta-\theta_{old})\&lt;/p&gt;
&lt;p&gt;&amp;amp; s.t.\frac12(\theta-\theta_{old})^TH(\theta-\theta_{old})\leq\delta \end{aligned} \tag{4.29}
$$
其中, g就是$\nabla_{\theta}L_{\theta_{old}}(\theta_{old})$ , H就是7.28的海森矩阵.&lt;/p&gt;
&lt;p&gt;后面实际上就是一个拉格朗日函数函数求极值的问题,用到的Krylov子空间迭代求解和Fletcher - Reeves共轭梯度的算法.&lt;/p&gt;
&lt;p&gt;推导到这里我们悬崖勒马, 其一是因为信任域的思想已经体现了出来, 后面就是纯数学推导和细节优化了, 其二是因为TRPO催生出的PPO算法, 已经全面优于TRPO, 执着于其完整推导没有意义. 等到有契机的话, 再回来对照代码好好推...&lt;/p&gt;
&lt;p&gt;我们将TRPO的思想直观展现出来如下图, 人在上山的过程中, 先四周探路得到值得信任的下限, 按照下限上升方向直接冲过去就一定是优化.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251101014949.D-sGfXQc.png&amp;amp;w=1556&amp;amp;h=866&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;六. Proximal Policy Optimization (PPO)算法&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;TRPO的复杂性催生了PPO算法, 它通过三种方式进行优化: 裁剪目标函数, 自适应惩罚和一阶优化. 既保持了TRPO的稳定性, 又大幅简化了实现和提高了运算效率. 已成为最主流的策略优化算法之一. 这个要认真看,特别是PPO-Clip, 公式看起来复杂实则简单.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;PPO算法通过与环境交互采样数据/利用随机梯度上升优化一个代理目标函数交替进行. 与标准策略梯度方法每次仅对一个数据样本执行一次梯度更新不同, PPO算法提出了新颖的目标函数, 支持进行多轮小批量更新. 这种算法被称为&lt;strong&gt;近端策略优化 (Proximal Policy Optimization, PPO)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;首先, 过去的算法存在或多或少的问题, 论文中列举如下:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;带有函数近似的Q-learning在许多简单问题上都会失败, 且其原理尚不明确.&lt;/li&gt;
&lt;li&gt;传统的梯度策略方法存在数据效率低和稳健型差的问题.&lt;/li&gt;
&lt;li&gt;TRPO相对复杂, 且无法与包含噪声(如Dropout) 或参数共享 (在策略和价值函数之间, 或在辅助任务之间)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;其中2和3都比较容易理解, 但是1我们要进行进一步说明. 虽然表格形Q-learning有坚实的理论保证, 我们在前进行了详细的推理和证明其可以收敛到最优Q函数, 但是变成DQN后, 这些理论基础实际上就失灵了. 非线性函数近似器 (如神经网络) 的表达能力极强, 但是它的优化landscape非常复杂, 是非凸的, 无法保证梯度下降能找到最优解, 甚至不能保证它能稳定在一个局部最优解.&lt;/p&gt;
&lt;p&gt;再进一步来说, DQN包含了三个不稳定因素, 函数近似+自举+离策略学习. 函数近似就是前面说的, 这种近似器是泛化的, 当针对某一个Q更新网络的参数会意外改变多对Q值, 产生牵连效应. 而自举的性质更是给这个近似误差加上了放大镜, 因为它通过自己更新自己, 会造成误差传播的恶性循环. 最后, 离策略学习指的是其异策略的考量, 通过“不相关”的数据来拟合目前的目标, 导致的更新不稳定.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;AC框架的Critic网络虽然避免了离策略学习, 避免了max操作的不稳定性, 但是自举和近似的挑战还在进行中. AC框架没有消除Critic训练的根本问题, 但通过它的系统架构设计, 将这些问题影响降到了可管理的水平.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;所以说,在开发一种可拓展 (适用于大型模型和并行实现)、数据高效且稳健 (即无需调整超参数即可解决多种问题) 的方法方面, 仍有许多改进空间.&lt;/p&gt;
&lt;p&gt;PPO算法在仅使用一阶优化的同时, 实现了与TRPO相当的数据效率和可靠性能. 新设计的目标函数在使用了截断的概率比, 从而对策略性能形成了一个悲观估计 (下界) . 为了优化策略, 我们从策略中采样数据与对所采样数据进行若干轮优化之间交替进行.&lt;/p&gt;
&lt;h2&gt;1. 重要性采样 (importance sampling)&lt;/h2&gt;
&lt;p&gt;假设我们不能从p中采集数据, 但是又想得到p的期望怎么办? 其中一个很自然的想法, 就是用到另一种q分布中采样. 注意, 我们的目的是通过采样来估计期望, 所以只要保证替换完之后的期望不变就可以:
$$
\mathbb{E}&lt;em&gt;{x\sim p}[f(x)]=\int f(x)p(x)\mathrm{d}x=\int f(x)\frac{p(x)}{q(x)}q(x)\mathrm{d}x=\mathbb{E}&lt;/em&gt;{x\sim q}[f(x)\frac{p(x)}{q(x)}] \tag{6.1.1}
$$
也就是说, 我们每次从q中采集数据, 都要乘以一个&lt;strong&gt;重要性权重(importance weight)&lt;/strong&gt; 来修正两个分布的差距. 这种策略就叫做重要性采样.&lt;/p&gt;
&lt;p&gt;需要注意的是, 虽然我们保证了E一致, 但是没有保持方差一致. 所以为了让两者更靠近, 必须要采集更多的数据, 我们考察下面一种情况:
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251030192128.CRRppldS.png&amp;amp;w=594&amp;amp;h=354&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
我们无法保证采集都在一个区间, 为了尽可能缩小这样的情况, 我们必须要尽量更多的采样, 或者用某些手段来限制两个函数的差距. 这个后面会进行说明.&lt;/p&gt;
&lt;p&gt;至于为什么要引入重要性采样. 一言以蔽之, PPO算法通过重要性采样来用旧策略更新新策略, 主要就是为了增加样本的效率和稳定性. :&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;样本效率: 在强化学习中, 与环境交互收集数据通常是非常耗时的, 如果每次更新策略后都要重新收集数据, 那么样本效率会很低. 重要性采样允许用旧策略收集的数据来估计新策略的梯度, 从而多次使用同一批数据.&lt;/li&gt;
&lt;li&gt;稳定性: 通过旧策略的数据, 并约束新旧策略的差异, 可以避免策略更新步幅过大, 从而稳定训练.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;2. PPO算法&lt;/h2&gt;
&lt;p&gt;PPO算法的核心是通过重要性采样, 将同策略变成异策略. 我们不需要策略$\theta$ 直接与环境交互, 而是使用旧策略$\pi_\theta&apos;$ ,它的工作是做示范 (demonstration):
$$
\nabla\bar{R}&lt;em&gt;\theta=\mathbb{E}&lt;/em&gt;{\tau\sim p_{\theta^{\prime}(\tau)}}\left[\frac{p_\theta(\tau)}{p_{\theta^{\prime}}(\tau)}R(\tau)\nabla\log p_\theta(\tau)\right] \tag{6.2.1}
$$&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这里我插一嘴, 前面的章节中经常都把‘ 作为“下一步”, 但是这里的行为策略$\theta&apos;$, 其实表示的“之前的”, 或者可以写作$\theta_{old}$ , 而$p$和$\pi$ 亦有混杂使用, 虽然都表示决策的概率. 可能我学习的主要资料之一蘑菇书EasyRL不同章节书写人员不同, 没有对符号进行统一, 这样要读懂全部公式很困扰... 所以我会在容易造成歧义的公式下面下上解释.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;还有一点, EazyRL中将策略$\theta&apos;$ 看成是另一个Actor, 这是很容易引起误解的说法, 其实更准确的说法是一个Actor在不同时间的快照, &lt;strong&gt;PPO是同策略的&lt;/strong&gt;. 当然作者后面解释了通过KL、Clip约束, 其实这两个策略相近, 但是一开始就不用另一个Actor这种误导性比喻就行啦...&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这样限制有显著的好处, 现在与环境交互的是$\theta&apos;$  而不是$\theta$, 所以采样的数据与$\theta$ 本身是没有关系的. 因此我们就可以让 $\theta&apos;$ 与环境交互采样大量的数据, $\theta$  可以多次更新参数, 一直到 $\theta$  训练到一定的程度. 更新多次以后, $\theta&apos;$ 再重新做采样.&lt;/p&gt;
&lt;p&gt;我们可以将实际做策略梯度的时候, 并不是给整个轨迹$\tau$ 一样的分数, 而是将每一个状态-动作对分开计算, 实际更新梯度的过程可以写作下式:
$$
\mathbb{E}&lt;em&gt;{\left(\textit{s}&lt;/em&gt;{t},\textit{a}&lt;em&gt;{t}\right)\sim\pi&lt;/em&gt;{ \theta}}\left[A^{\theta}\left(\textit{s}&lt;em&gt;{t},\textit{a}&lt;/em&gt;{t}\right)\nabla\log p &lt;em&gt;{\theta}\left(a&lt;/em&gt;{t}^{n}|\textit{s}&lt;em&gt;{t}^{n}\right)\right] \tag{6.2.2}
$$
其中, 这个状态-动作对的优势$A^{\theta}\left(\textit{s}&lt;/em&gt;{t},\textit{a}_{t}\right)$ 是一个用累积奖励减去基线 (baseline)的量.&lt;/p&gt;
&lt;p&gt;但是, 如上述理由如1中所说, 我们就需要一个量来限制两个人的差距. 为了得到两个分布的距离, 我们自然而然就想到通过一个量来限制.&lt;/p&gt;
&lt;p&gt;需要注意的是, 虽然进行了重要性采样, 但是约束由于约束, 行为策略$\theta&apos;$ 和目标策略$\theta$ 非常接近, 所以两者可以看成是同一个策略, 因此&lt;strong&gt;PPO是同策略算法&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;至于怎么来限制, 具体而言有两种重要变种:&lt;/p&gt;
&lt;h3&gt;(1) 近端策略优化惩罚 (PPO-penalty)&lt;/h3&gt;
&lt;p&gt;$$
J_{{\mathrm{PPO}}}^{{\theta^{k}}}(\theta)=J^{{\theta^{k}}}(\theta)-\beta{\mathrm{KL}}\left(\theta,\theta^{k}\right) \tag{6.2.3}
$$&lt;/p&gt;
&lt;p&gt;TRPO把KL散度当作约束, 希望两者差距小于$\delta$, 而PPO直接把约束放在了要优化的式子里, 实现了自适应惩罚.&lt;/p&gt;
&lt;h3&gt;(2) 近端策略优化裁剪 (PPO-clip)&lt;/h3&gt;
&lt;p&gt;$$
J_{\text{PPO2}}^{\theta^{k}}(\theta) \approx \sum_{(s_{t},a_{t})} \min \left(\frac{p_{\theta}\left(a_{t}|s_{t}\right)}{p_{\theta^{k}}\left(a_{t}|s_{t}\right)}A^{\theta^{k}}\left(s_{t},a_{t}\right),\right. \left.\text{clip}\left(\frac{p_{\theta}\left(a_{t}|s_{t}\right)}{p_{\theta^{k}}\left(a_{t}|s_{t}\right)},1-\varepsilon,1+\varepsilon\right)A^{\theta^{k}}\left(s_{t},a_{t}\right)\right) \tag{6.2.4}
$$&lt;/p&gt;
&lt;p&gt;PPO算法的裁剪起到和信赖域相似的作用, 阻止了步子迈的太大, 但是不需要重新计算, 只做裁剪, 大大优化了性能. 本质上是设计了一个“动态信任域”, 因为对&lt;strong&gt;领&lt;/strong&gt;域进行约束, 被称为&lt;strong&gt;近端&lt;/strong&gt;策略优化.&lt;/p&gt;
&lt;p&gt;直接看上面的式子, 我估计肯定是懵逼, 有一种自己学了这么久基础结果还是被一下子干碎的荒谬感. 别急, 我们来拆解一下. 首先, 我们想优化的期望是:
$$
J(\theta)=\mathbb{E}&lt;em&gt;{a \sim \pi&lt;/em&gt;\theta}\left[A(s,a)\right] \tag{6.2.5}
$$
这也是AC框架下的期望统一表达, 然后, 我们回忆策略梯度的期望形式, 将其中的奖励函数R变成现在的AC框架中的优势函数A, 即:
$$
\nabla_{\theta}J(\theta)=\mathbb{E}&lt;em&gt;{a \sim \pi&lt;/em&gt;{\theta}}\left[  \nabla_\theta log\pi_\theta(a|s)A(s,a) \right] \tag{6.2.6}
$$
但是, &lt;strong&gt;PPO没有直接采用这个梯度估计, 而是重新构建了一个目标函数&lt;/strong&gt;. 所以我们从6.2.5出发, 不是进行求导, 而是通过另外的方式推演, 首先我们将前面的重要性采样引入, 式子变为:
$$
J(\theta)=\mathbb{E}&lt;em&gt;{a \sim \pi&lt;/em&gt;{\theta_{old}}}\left[ \frac{\pi_\theta(a|s)}{\pi_{\theta_{old}}(a|s)} A(s,a) \right] \tag{6.2.7}
$$&lt;/p&gt;
&lt;p&gt;为了简化书写, 我们可以把$\frac{\pi_\theta(a|s)}{\pi_{\theta_{old}}(a|s)}$ 写做&lt;strong&gt;概率比$r_t(\theta)$,&lt;/strong&gt; 把整个$A(s,a)$ 记为&lt;strong&gt;优势策略估计$\hat{A}$&lt;/strong&gt;. 然后, 在时间步t内的损失, 就可以写成很简洁的形式:
$$
J(\theta)=\hat{\mathbb{E}}[(r_t(\theta)\hat{A})]\tag{6.2.8}
$$
然后, 我们希望限制更新的步子, 即在第t个时间步内, 最多只能在一定范围内更新. 这次我们使用一种简单粗暴的方法 -- 如果更新太多了, 就进行截断, 如下图所示:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251103215158.C6ghxCub.png&amp;amp;w=1558&amp;amp;h=822&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;为了实现这个目的, 我们可以引入截断函数clip, clip函数后面的两个量, 表示把函数值限制在这个范围内. 于是, 我们可以将式子进一步写成:
$$
J(\theta)=\hat{\mathbb{E}}[min(r_t(\theta)\hat{A},clip(r_t(\theta),1-\epsilon,1+\epsilon)\hat{A})]\tag{6.2.9}
$$&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;在梯度形式的式子6.2.7中, 按理说除去表示方向的得分函数, 优势函数和概率比的乘积应该是更新的幅度, 可是为什么是只对前面的概率比进行截断? 这是因为, 直接截断得分函数之外的部分, 实际上破坏了相对比较信息, 比如“很好”和“一般好”的动作可能被截断成一样的值, 这不是我们希望看到的. 我们只是希望更新的幅度被限制. 所以, 我们留下的得分函数表示方向, 留下优势函数表示优势比较.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;上述是期望形式, 写成采样形式就是一开始给出的:
$$
J^{\theta_{old}}(\theta) \approx \sum_{(s_{t},a_{t})} \min \left(\frac{p_{\theta}\left(a_{t}|s_{t}\right)}{p_{\theta_{old}}\left(a_{t}|s_{t}\right)}A^{\theta_{old}}\left(s_{t},a_{t}\right),\right. \left.\text{clip}\left(\frac{p_{\theta}\left(a_{t}|s_{t}\right)}{p_{\theta_{old}}\left(a_{t}|s_{t}\right)},1-\varepsilon,1+\varepsilon\right)A^{\theta_{old}}\left(s_{t},a_{t}\right)\right) \tag{6.2.10}
$$&lt;/p&gt;
&lt;p&gt;PPO-Clip既工程友好又理论单调改进, 该算法曾一度称为OpenAI的核心算法, 并迅速推广到Gym等框架示例中, 成为了很多人入门RL的选择. 这种工程化的约束思想, 启发了很多研究者, 继续沿着这条 “稳定优化+可控偏移” 的道路提出了很多变体. 它们&lt;strong&gt;将强化学习中RL最优解思想, 转化为大模型与人类对齐的优化框架&lt;/strong&gt;, 开启了整个LLM对齐家族的演化历程.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251103215651.B5vtDg3j.png&amp;amp;w=1204&amp;amp;h=1022&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>FastAPI Bigger Applications：APIRouter、多文件应用与生命周期</title><link>https://owen571.top/posts/study/fastapi/08-fastapi-apirouter-%E5%A4%9A%E6%96%87%E4%BB%B6%E5%BA%94%E7%94%A8%E4%B8%8E%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/08-fastapi-apirouter-%E5%A4%9A%E6%96%87%E4%BB%B6%E5%BA%94%E7%94%A8%E4%B8%8E%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F/</guid><description>当单文件应用开始变大，把 APIRouter、include_router、多文件结构和 lifespan 放到同一条工程化路径里看。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;FastAPI 的前面几章都还能在一个文件里完成，但一旦路由、依赖和安全逻辑变多，问题就会从“会不会写”变成“怎么组织”。&lt;/p&gt;
&lt;h2&gt;1. &lt;code&gt;APIRouter&lt;/code&gt; 解决的不是功能，而是组织&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;APIRouter&lt;/code&gt; 不是另一个小型 &lt;code&gt;FastAPI&lt;/code&gt;，它更像“可组合的路由组”。&lt;/p&gt;
&lt;p&gt;官方 Bigger Applications 页里最值得留下来的直觉是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;应用不是只有一个 &lt;code&gt;app&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;路由可以先在各自模块里组织好&lt;/li&gt;
&lt;li&gt;最后再由主应用统一挂载&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 最常见的多文件结构&lt;/h2&gt;
&lt;p&gt;这类结构是最典型的起点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app/
├── __init__.py
├── main.py
├── dependencies.py
└── routers/
    ├── __init__.py
    ├── items.py
    └── users.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的分层已经很清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;main.py&lt;/code&gt;：组装应用&lt;/li&gt;
&lt;li&gt;&lt;code&gt;routers/&lt;/code&gt;：各业务路由&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dependencies.py&lt;/code&gt;：共享依赖&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 在路由模块里定义 &lt;code&gt;APIRouter&lt;/code&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import APIRouter, Depends, HTTPException

from ..dependencies import get_token_header

router = APIRouter(
    prefix=&quot;/items&quot;,
    tags=[&quot;items&quot;],
    dependencies=[Depends(get_token_header)],
    responses={404: {&quot;description&quot;: &quot;Not found&quot;}},
)


@router.get(&quot;/&quot;)
async def read_items():
    return [{&quot;name&quot;: &quot;Foo&quot;}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一层最重要的点是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;prefix&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dependencies&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;responses&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;都可以直接挂在 &lt;code&gt;APIRouter&lt;/code&gt; 上，而不用在每个路径操作里重复写。&lt;/p&gt;
&lt;h2&gt;4. 在主应用里 &lt;code&gt;include_router&lt;/code&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import Depends, FastAPI

from .dependencies import get_query_token
from .routers import items, users, admin

app = FastAPI(dependencies=[Depends(get_query_token)])

app.include_router(users.router)
app.include_router(items.router)
app.include_router(
    admin.router,
    prefix=&quot;/admin&quot;,
    tags=[&quot;admin&quot;],
    dependencies=[Depends(get_token_header)],
    responses={418: {&quot;description&quot;: &quot;I&apos;m a teapot&quot;}},
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档里这段很有代表性，因为它说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;路由模块本身可以保持通用&lt;/li&gt;
&lt;li&gt;应用层再决定怎么挂载它&lt;/li&gt;
&lt;li&gt;&lt;code&gt;include_router()&lt;/code&gt; 时还能继续补 &lt;code&gt;prefix&lt;/code&gt;、&lt;code&gt;tags&lt;/code&gt;、&lt;code&gt;dependencies&lt;/code&gt;、&lt;code&gt;responses&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;来源：Bigger Applications 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/bigger-applications/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/bigger-applications/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;5. 这样组织的真正好处&lt;/h2&gt;
&lt;p&gt;这不是为了“目录看起来整齐”，而是为了几件更实在的事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;共享路由模块更容易&lt;/li&gt;
&lt;li&gt;共享依赖逻辑更自然&lt;/li&gt;
&lt;li&gt;主应用装配时灵活度更高&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/docs&lt;/code&gt; 里的标签分组也更清楚&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. &lt;code&gt;lifespan&lt;/code&gt;：把应用级初始化和清理写成一对&lt;/h2&gt;
&lt;p&gt;如果有些资源应该在应用启动时加载、关闭时释放，FastAPI 现在更推荐用 &lt;code&gt;lifespan&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from contextlib import asynccontextmanager
from fastapi import FastAPI

ml_models = {}


def fake_answer_to_everything_ml_model(x: float):
    return x * 42


@asynccontextmanager
async def lifespan(app: FastAPI):
    ml_models[&quot;answer_to_everything&quot;] = fake_answer_to_everything_ml_model
    yield
    ml_models.clear()


app = FastAPI(lifespan=lifespan)


@app.get(&quot;/predict&quot;)
async def predict(x: float):
    result = ml_models[&quot;answer_to_everything&quot;](x)
    return {&quot;result&quot;: result}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档明确写到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;yield&lt;/code&gt; 之前在应用启动前执行&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yield&lt;/code&gt; 之后在应用结束时清理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;来源：Lifespan 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/advanced/events/&quot;&gt;https://fastapi.tiangolo.com/zh/advanced/events/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;7. &lt;code&gt;lifespan&lt;/code&gt; 和依赖里的 &lt;code&gt;yield&lt;/code&gt; 有什么不同&lt;/h2&gt;
&lt;p&gt;它们都用到了 &lt;code&gt;yield&lt;/code&gt;，但层级不一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;依赖里的 &lt;code&gt;yield&lt;/code&gt;：围绕一次请求&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lifespan&lt;/code&gt;：围绕整个应用生命周期&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以适合放在 &lt;code&gt;lifespan&lt;/code&gt; 里的，通常是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型预加载&lt;/li&gt;
&lt;li&gt;全局连接池初始化&lt;/li&gt;
&lt;li&gt;应用级缓存&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而数据库 session 这种更短命的资源，还是更适合放依赖里。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 核心组件 01：Models</title><link>https://owen571.top/posts/study/langchain/03-models/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/03-models/</guid><description>先把模型对象本身看明白：如何初始化、调用、流式输出，以及模型层负责什么、不负责什么。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;从这里开始，学习顺序正式进入“先组件，后 Agents”。Models 是最先该熟悉的，因为几乎所有上层能力最终都要落回模型调用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;大语言模型是功能强大的人工智能工具，能够像人类一样理解和生成文本。它们用途广泛，无需针对每项任务进行专门训练，即可完成内容创作、语言翻译、文本摘要和问答等工作。&lt;/p&gt;
&lt;p&gt;除文本生成外，许多模型还支持以下功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工具调用—— 调用外部工具（如数据库查询或 API 调用），并将结果应用于回复中。&lt;/li&gt;
&lt;li&gt;结构化输出—— 约束模型的输出遵循指定格式。&lt;/li&gt;
&lt;li&gt;多模态能力—— 处理并返回文本以外的数据，如图像、音频和视频。&lt;/li&gt;
&lt;li&gt;推理能力—— 模型通过多步推理得出结论。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;模型是智能体的推理引擎，驱动智能体的决策过程，决定调用哪些工具、如何解读结果以及何时给出最终答案。&lt;/p&gt;
&lt;p&gt;你所选择模型的质量与能力，直接影响智能体的基础可靠性和运行性能。不同模型擅长不同任务 —— 部分模型更擅长遵循复杂指令，部分擅长结构化推理，还有部分支持更大的上下文。&lt;/p&gt;
&lt;p&gt;当然。以上是废话。&lt;/p&gt;
&lt;h2&gt;2. Basic Usage&lt;/h2&gt;
&lt;p&gt;Models有两种方法使用，一种是作为agent的大脑，详见上一章；另一个是在agent loop外直接被调用&lt;/p&gt;
&lt;h3&gt;(1) 初始化模型&lt;/h3&gt;
&lt;p&gt;感觉其实不用我多说什么，主要用的就是&lt;code&gt;init_chat_model&lt;/code&gt;和Model Class两种方案，在上上一章中已经尝试并使用过。前者是通用的创建方法，后者是特定代理商的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from langchain.chat_models import init_chat_model

os.environ[&quot;OPENAI_API_KEY&quot;] = &quot;sk-...&quot;

model = init_chat_model(&quot;gpt-5.2&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;import os
from langchain_openai import ChatOpenAI

os.environ[&quot;OPENAI_API_KEY&quot;] = &quot;sk-...&quot;

model = ChatOpenAI(model=&quot;gpt-5.2&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 支持的模型&lt;/h3&gt;
&lt;p&gt;看&lt;a href=&quot;https://docs.langchain.com/oss/python/integrations/providers/overview&quot;&gt;这里&lt;/a&gt;，主流的都支持。&lt;/p&gt;
&lt;h2&gt;3. 参数&lt;/h2&gt;
&lt;p&gt;Chat Model 在初始化时可以传入一系列参数来控制模型行为。不同模型、不同 Provider 支持的参数并不完全一致，但有一些是比较通用的。&lt;/p&gt;
&lt;p&gt;常见参数如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;model&lt;/code&gt;&lt;br /&gt;
指定要使用的具体模型名称或标识符。&lt;br /&gt;
有时也可以把 provider 一起写进去，比如 &lt;code&gt;openai:gpt-5&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;api_key&lt;/code&gt;&lt;br /&gt;
用于向模型提供商鉴权的密钥。&lt;br /&gt;
一般通过环境变量读取，也可以在初始化时直接传入。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;temperature&lt;/code&gt;&lt;br /&gt;
控制输出的随机性。&lt;br /&gt;
值越高，回答通常越发散、越有创造性；值越低，回答越稳定、越接近确定性输出。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;max_tokens&lt;/code&gt;&lt;br /&gt;
限制模型本次最多生成多少 token。&lt;br /&gt;
可以粗略理解为控制“回复最长能写多长”。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;br /&gt;
请求超时时间。&lt;br /&gt;
如果超过设定时间模型还没有返回结果，请求就会被取消。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;max_retries&lt;/code&gt;&lt;br /&gt;
请求失败时的最大重试次数。&lt;br /&gt;
常见的网络超时、限流（429）或服务端错误（5xx）通常会自动重试；像 401、404 这类客户端错误一般不会重试。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;使用 &lt;code&gt;init_chat_model&lt;/code&gt; 时，这些参数通常可以直接作为关键字参数传入，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model

model = init_chat_model(
    &quot;claude-sonnet-4-6&quot;,
    temperature=0.7,
    timeout=30,
    max_tokens=1000,
    max_retries=6,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 调用 (Invocation)&lt;/h2&gt;
&lt;p&gt;必须调用聊天模型才能生成输出结果。共有三种主要的调用方法，每种方法适用于不同的使用场景。&lt;/p&gt;
&lt;h3&gt;(1) Invoke&lt;/h3&gt;
&lt;p&gt;可以记得，agent就是使用invoke来创建一次回答的对象，model同样如此，很简单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;response = model.invoke(&quot;Why do parrots have colorful feathers?&quot;)
print(response)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以向对话模型提供消息列表来表示对话历史。每条消息都带有一个角色，模型通过该角色来标识对话中消息的发送方。这里之后会在Messages组件中细聊：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;conversation = [
    {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;You are a helpful assistant that translates English to French.&quot;},
    {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Translate: I love programming.&quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;J&apos;adore la programmation.&quot;},
    {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Translate: I love building applications.&quot;}
]

response = model.invoke(conversation)
print(response)  # AIMessage(&quot;J&apos;adore créer des applications.&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import HumanMessage, AIMessage, SystemMessage

conversation = [
    SystemMessage(&quot;You are a helpful assistant that translates English to French.&quot;),
    HumanMessage(&quot;Translate: I love programming.&quot;),
    AIMessage(&quot;J&apos;adore la programmation.&quot;),
    HumanMessage(&quot;Translate: I love building applications.&quot;)
]

response = model.invoke(conversation)
print(response)  # AIMessage(&quot;J&apos;adore créer des applications.&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们这里，直接print来看一下AIMessage里面是什么内容。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AIMessage(
    content=&quot;J&apos;adore creer des applications.&quot;,
    additional_kwargs={
        &quot;refusal&quot;: None
    },
    response_metadata={
        &quot;token_usage&quot;: {
            &quot;completion_tokens&quot;: 7,
            &quot;prompt_tokens&quot;: 48,
            &quot;total_tokens&quot;: 55,
            &quot;completion_tokens_details&quot;: {
                &quot;accepted_prediction_tokens&quot;: 0,
                &quot;audio_tokens&quot;: 0,
                &quot;reasoning_tokens&quot;: 0,
                &quot;rejected_prediction_tokens&quot;: 0
            },
            &quot;prompt_tokens_details&quot;: {
                &quot;audio_tokens&quot;: 0,
                &quot;cached_tokens&quot;: 0
            }
        },
        &quot;model_provider&quot;: &quot;openai&quot;,
        &quot;model_name&quot;: &quot;gpt-4o-mini-2024-07-18&quot;,
        &quot;system_fingerprint&quot;: &quot;fp_eb37e061ec&quot;,
        &quot;id&quot;: &quot;chatcmpl-DNt7u2YGuVpI3LG99vAdG3aG486te&quot;,
        &quot;finish_reason&quot;: &quot;stop&quot;,
        &quot;logprobs&quot;: None
    },
    id=&quot;lc_run--019d2d8e-ad56-7d90-9180-83c9de41a83b-0&quot;,
    tool_calls=[],
    invalid_tool_calls=[],
    usage_metadata={
        &quot;input_tokens&quot;: 48,
        &quot;output_tokens&quot;: 7,
        &quot;total_tokens&quot;: 55,
        &quot;input_token_details&quot;: {
            &quot;audio&quot;: 0,
            &quot;cache_read&quot;: 0
        },
        &quot;output_token_details&quot;: {
            &quot;audio&quot;: 0,
            &quot;reasoning&quot;: 0
        }
    }
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到，其中 &lt;code&gt;content&lt;/code&gt; 表示模型真正回复的文本；&lt;code&gt;usage_metadata&lt;/code&gt; 是 LangChain 统一整理后的 token 使用情况；&lt;code&gt;response_metadata&lt;/code&gt; 则更多保存模型提供商返回的原始元数据，例如模型名称、结束原因、logprobs 和更细粒度的 token usage 信息。如果本次回复涉及工具调用，还会在 &lt;code&gt;tool_calls&lt;/code&gt; 中体现；如果没有，则通常是空列表。&lt;/p&gt;
&lt;p&gt;现在你应该理解Model那一章从哪里得到的元数据字段（说实话不明白为什么官网Messages要放在Model后面呢，还有为什么Agents要放在Messages前面）。&lt;/p&gt;
&lt;p&gt;对比一下Agents，它被invoke的时候一般返回agent当前的最终state，这是一个结果字典。我们用response[&quot;messages&quot;][-1]看到的才是AIMessage。&lt;/p&gt;
&lt;p&gt;如果这里print发现直接返回直接就是字符串，检查一下用的是不是对话模型。LangChain的对话模型都是用Chat作为前缀。&lt;/p&gt;
&lt;h3&gt;(2) Stream&lt;/h3&gt;
&lt;p&gt;大多数模型能够在生成输出内容的同时进行流式传输。通过逐步展示输出结果，流式传输可显著提升用户体验，对于较长的响应尤为明显。&lt;/p&gt;
&lt;p&gt;调用stream()会返回一个迭代器，该迭代器会在输出片段生成时逐一产出。你可以使用循环来实时处理每个片段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for chunk in model.stream(&quot;Why do parrots have colorful feathers?&quot;):
    print(chunk.text, end=&quot;|&quot;, flush=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;与invoke()不同，该方法会在模型完成完整响应生成后返回单个AIMessage；而stream()会返回多个AIMessageChunk对象，每个对象均包含输出文本的一部分。重要的是，流中的每个数据块都可通过累加方式拼接成完整消息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;full = None  # None | AIMessageChunk
for chunk in model.stream(&quot;What color is the sky?&quot;):
    full = chunk if full is None else full + chunk
    print(full.text)

# The
# The sky
# The sky is
# The sky is typically
# The sky is typically blue
# ...

print(full.content_blocks)
# [{&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;The sky is typically blue...&quot;}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最终生成的消息可以和invoke()生成的消息同等对待 —— 例如，可将其整合至消息历史中，并作为对话上下文回传给模型。&lt;/p&gt;
&lt;h3&gt;(3) Batch&lt;/h3&gt;
&lt;p&gt;将一批独立的模型请求进行批处理，能够显著提升性能并降低成本，因为处理过程可以并行执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;responses = model.batch([
    &quot;Why do parrots have colorful feathers?&quot;,
    &quot;How do airplanes fly?&quot;,
    &quot;What is quantum computing?&quot;
])
for response in responses:
    print(response)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认情况下batch()只会返回整个批次的最终输出结果，没如果需要每次都有结果需要用batch_as_completed()：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for response in model.batch_as_completed([
    &quot;Why do parrots have colorful feathers?&quot;,
    &quot;How do airplanes fly?&quot;,
    &quot;What is quantum computing?&quot;
]):
    print(response)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果可能会乱序返回，但每个结果都会包含输入索引，可根据需要通过匹配来还原原始顺序。&lt;/p&gt;
&lt;h2&gt;5. 工具调用 (Tool calling)&lt;/h2&gt;
&lt;p&gt;模型可以请求调用工具来执行各类任务，例如从数据库获取数据、进行网络搜索或运行代码。工具由以下两部分配对组成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个schema，包含工具名称、描述以及 / 或者参数定义（通常为 JSON 模式）&lt;/li&gt;
&lt;li&gt;用于执行的函数或协程。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;注意function calling和tool calling在这里表示一个意思，混用。&lt;/p&gt;
&lt;p&gt;下面是用户与模型之间的基本工具调用流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant U as User
    participant M as Model
    participant T as Tools

    U-&amp;gt;&amp;gt;M: &quot;What&apos;s the weather in SF and NYC?&quot;
    M-&amp;gt;&amp;gt;M: Analyze request &amp;amp; decide tools needed

    par Parallel Tool Calls
        M-&amp;gt;&amp;gt;T: get_weather(&quot;San Francisco&quot;)
        M-&amp;gt;&amp;gt;T: get_weather(&quot;New York&quot;)
    end

    par Tool Execution
        T--&amp;gt;&amp;gt;M: SF weather data
        T--&amp;gt;&amp;gt;M: NYC weather data
    end

    M-&amp;gt;&amp;gt;M: Process results &amp;amp; generate response
    M-&amp;gt;&amp;gt;U: &quot;SF: 72°F sunny, NYC: 68°F cloudy&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型要想使用自定义工具，必须要通过&lt;code&gt;bind_tools&lt;/code&gt;方法将其绑定，那么在后续调用过程中，模型就可以根据需要选择调用任意已绑定的工具。比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool

@tool
def get_weather(location: str) -&amp;gt; str:
    &quot;&quot;&quot;Get the weather at a location.&quot;&quot;&quot;
    return f&quot;It&apos;s sunny in {location}.&quot;


model_with_tools = model.bind_tools([get_weather])

response = model_with_tools.invoke(&quot;What&apos;s the weather like in Boston?&quot;)
for tool_call in response.tool_calls:
    # View tool calls made by the model
    print(f&quot;Tool: {tool_call[&apos;name&apos;]}&quot;)
    print(f&quot;Args: {tool_call[&apos;args&apos;]}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在绑定用户自定义工具时，模型的响应会包含一个请求以执行工具。当独立于智能体使用模型时，需要由你自行执行所请求的工具，并将结果返回给模型，供其在后续推理中使用。而在使用智能体时，智能体循环会为你自动处理工具执行流程。（所以工具还是写在Agent里面好，咳咳）&lt;/p&gt;
&lt;h2&gt;6. Structured output&lt;/h2&gt;
&lt;p&gt;格式化输出有三种方式可以做，Pydantic、TypedDict和Json Schema，定义好了作为参数传给 &lt;code&gt;model.with_structured_output&lt;/code&gt;函数即可。&lt;/p&gt;
&lt;h2&gt;7. Advanced topic&lt;/h2&gt;
&lt;h3&gt;(1) Model profiles&lt;/h3&gt;
&lt;p&gt;LangChain 聊天模型可以通过profile属性公开一个包含其所支持功能与特性的字典，让应用根据模型能力动态适配（这部分数据很多来自 models.dev，并且这是 beta feature，格式后面可能会变）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model.profile
# {
#   &quot;max_input_tokens&quot;: 400000,
#   &quot;image_inputs&quot;: True,
#   &quot;reasoning_output&quot;: True,
#   &quot;tool_calling&quot;: True,
#   ...
# }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 多模态 (Multimodal)&lt;/h3&gt;
&lt;p&gt;部分模型能够处理并返回图像、音频和视频等非文本数据。你可以通过提供内容块（这一部分在Message中介绍）来向模型传递非文本数据。&lt;/p&gt;
&lt;p&gt;然后有些模型还可以返回多模态数据，生成的AIMessage将包含带有多模态类型的内容块&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;response = model.invoke(&quot;Create a picture of a cat&quot;)
print(response.content_blocks)
# [
#     {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;Here&apos;s a picture of a cat&quot;},
#     {&quot;type&quot;: &quot;image&quot;, &quot;base64&quot;: &quot;...&quot;, &quot;mime_type&quot;: &quot;image/jpeg&quot;},
# ]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) 推理 (Reasoning)&lt;/h3&gt;
&lt;p&gt;许多模型支持推理，可以选择呈现推理过程。流式推理输出如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for chunk in model.stream(&quot;Why do parrots have colorful feathers?&quot;):
    reasoning_steps = [r for r in chunk.content_blocks if r[&quot;type&quot;] == &quot;reasoning&quot;]
    print(reasoning_steps if reasoning_steps else chunk.text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Complete展现推理如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;response = model.invoke(&quot;Why do parrots have colorful feathers?&quot;)
reasoning_steps = [b for b in response.content_blocks if b[&quot;type&quot;] == &quot;reasoning&quot;]
print(&quot; &quot;.join(step[&quot;reasoning&quot;] for step in reasoning_steps))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(4) 本地大模型&lt;/h3&gt;
&lt;p&gt;这……好像跟LangChain本身关系不大，如果用到的时候查一下接法。&lt;/p&gt;
&lt;h3&gt;(5) Prompt catching&lt;/h3&gt;
&lt;p&gt;也就是提示词缓存技术，以降低重复处理相同令牌时的延迟和成本。不同模型的供应商不同，OpenAI和Gemini其实等提供了隐式提示词缓存。服务器提供商也允许用户手动指定缓存节点，比如ChatOpenAI的prompt_cache_key。&lt;/p&gt;
&lt;h3&gt;(6) 服务端工具调用&lt;/h3&gt;
&lt;p&gt;pass&lt;/p&gt;
&lt;h3&gt;(7) 限额&lt;/h3&gt;
&lt;p&gt;pass&lt;/p&gt;
&lt;h3&gt;(8) Base URL and proxy settings&lt;/h3&gt;
&lt;p&gt;这个特性我们之前就用过，就是第三方&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model = init_chat_model(
    model=&quot;MODEL_NAME&quot;,
    model_provider=&quot;openai&quot;,
    base_url=&quot;BASE_URL&quot;,
    api_key=&quot;YOUR_API_KEY&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(9) Log probabilities&lt;/h3&gt;
&lt;p&gt;做实验的时候可能会需要。某些模型可通过在初始化模型时设置logprobs参数，配置为返回代表指定令牌概率的令牌级对数概率：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model = init_chat_model(
    model=&quot;gpt-4.1&quot;,
    model_provider=&quot;openai&quot;
).bind(logprobs=True)

response = model.invoke(&quot;Why do parrots talk?&quot;)
print(response.response_metadata[&quot;logprobs&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回回来的将是这样的结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;content&quot;: [...],
  &quot;refusal&quot;: None
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;content是模型生成出来的 token 明细列表，refusal是否触发拒答，这里是 None，说明没有拒答。&lt;/p&gt;
&lt;p&gt;而content中的每一项，大概是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;token&quot;: &quot;Par&quot;,
  &quot;bytes&quot;: [80, 97, 114],
  &quot;logprob&quot;: -5.512236498361744e-07,
  &quot;top_logprobs&quot;: []
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;字段的意思是，这一个token对应的字节显示，还有选中这个token的对数概率，越接近0越稳定。粗略可以记为0附近很有把握，-0.1到-1还比较合理，-2以下没这么稳了。（-0.69对应的概率差不多是0.5，-2.3对应的差不多是0.1）。&lt;/p&gt;
&lt;p&gt;另外，注意到像 &lt;code&gt;logprobs&lt;/code&gt; 这类更偏 provider-specific 的信息，则通常放在 &lt;code&gt;response_metadata&lt;/code&gt; 中&lt;/p&gt;
&lt;h3&gt;(10) Token usage&lt;/h3&gt;
&lt;p&gt;多家模型提供商会在调用响应中返回令牌使用信息。如果该信息可用，将会被包含在对应模型生成的AIMessage对象中。但是不能按照response.tokens这种方式读，因为他们不是AIMessage的顶层属性，而是在&lt;code&gt;usage_metadata&lt;/code&gt;中。&lt;/p&gt;
&lt;p&gt;可以使用回调函数或上下文管理器来跟踪应用程序中不同模型的总令牌使用数量，如下所示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model
from langchain_core.callbacks import UsageMetadataCallbackHandler

model_1 = init_chat_model(model=&quot;gpt-4.1-mini&quot;)
model_2 = init_chat_model(model=&quot;claude-haiku-4-5-20251001&quot;)

callback = UsageMetadataCallbackHandler()
result_1 = model_1.invoke(&quot;Hello&quot;, config={&quot;callbacks&quot;: [callback]})
result_2 = model_2.invoke(&quot;Hello&quot;, config={&quot;callbacks&quot;: [callback]})
print(callback.usage_metadata)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model
from langchain_core.callbacks import get_usage_metadata_callback

model_1 = init_chat_model(model=&quot;gpt-4.1-mini&quot;)
model_2 = init_chat_model(model=&quot;claude-haiku-4-5-20251001&quot;)

with get_usage_metadata_callback() as cb:
    model_1.invoke(&quot;Hello&quot;)
    model_2.invoke(&quot;Hello&quot;)
    print(cb.usage_metadata)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们会得到如下的统计信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    &apos;gpt-4.1-mini-2025-04-14&apos;: {
        &apos;input_tokens&apos;: 8,
        &apos;output_tokens&apos;: 10,
        &apos;total_tokens&apos;: 18,
        &apos;input_token_details&apos;: {&apos;audio&apos;: 0, &apos;cache_read&apos;: 0},
        &apos;output_token_details&apos;: {&apos;audio&apos;: 0, &apos;reasoning&apos;: 0}
    },
    &apos;claude-haiku-4-5-20251001&apos;: {
        &apos;input_tokens&apos;: 8,
        &apos;output_tokens&apos;: 21,
        &apos;total_tokens&apos;: 29,
        &apos;input_token_details&apos;: {&apos;cache_read&apos;: 0, &apos;cache_creation&apos;: 0}
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(11) Invocation config&lt;/h3&gt;
&lt;p&gt;调用模型时，你可以通过config参数，使用RunnableConfig字典传递额外配置。这能够在运行时对执行行为、回调函数以及元数据追踪进行控制，如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;response = model.invoke(
    &quot;Tell me a joke&quot;,
    config={
        &quot;run_name&quot;: &quot;joke_generation&quot;,      # Custom name for this run
        &quot;tags&quot;: [&quot;humor&quot;, &quot;demo&quot;],          # Tags for categorization
        &quot;metadata&quot;: {&quot;user_id&quot;: &quot;123&quot;},     # Custom metadata
        &quot;callbacks&quot;: [my_callback_handler], # Callback handlers
    }
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些配置值在以下场景中尤为实用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用LangSmith追踪进行调试&lt;/li&gt;
&lt;li&gt;实现自定义日志记录或监控&lt;/li&gt;
&lt;li&gt;控制生产环境中的资源使用&lt;/li&gt;
&lt;li&gt;追踪复杂流程中的调用过程&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(12) Configurable models&lt;/h3&gt;
&lt;p&gt;你还可以通过指定configurable_fields来创建可在运行时配置的模型。若你未指定模型取值，那么&apos;model&apos;和&apos;model_provider&apos;将默认处于可配置状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model

configurable_model = init_chat_model(temperature=0)

configurable_model.invoke(
    &quot;what&apos;s your name&quot;,
    config={&quot;configurable&quot;: {&quot;model&quot;: &quot;gpt-5-nano&quot;}},  # Run with GPT-5-Nano
)
configurable_model.invoke(
    &quot;what&apos;s your name&quot;,
    config={&quot;configurable&quot;: {&quot;model&quot;: &quot;claude-sonnet-4-6&quot;}},  # Run with Claude
)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>LangGraph 应用思路 02：典型工作流与 Agent 模式</title><link>https://owen571.top/posts/study/langgraph/10-langgraph-%E5%85%B8%E5%9E%8B%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F/</link><guid isPermaLink="true">https://owen571.top/posts/study/langgraph/10-langgraph-%E5%85%B8%E5%9E%8B%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F/</guid><description>Prompt Chaining、Parallelization、Routing、Orchestrator-worker 与 Evaluator-optimizer 的结构化落地。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;用LangGraph实现典型工作模式&lt;/h1&gt;
&lt;p&gt;这一章介绍常见的工作流和agent模式。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工作流具有预设的代码路径，设计上按特定顺序运行。&lt;/li&gt;
&lt;li&gt;智能体则具备动态性，可自主定义执行流程与工具使用方式。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LangGraph 在构建智能体与工作流时具备多项优势，包括持久化、流式输出，同时支持调试以及部署功能。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-12.DbfIR861.png&amp;amp;w=2500&amp;amp;h=1119&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;1. Prompt Chaining&lt;/h2&gt;
&lt;p&gt;提示词链式调用是指每次大语言模型调用都会处理上一次调用的输出结果。它通常用于执行可拆解为更小、可验证步骤的明确任务。例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;将文档翻译成不同语言&lt;/li&gt;
&lt;li&gt;验证生成内容的一致性&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-13.BXr7NvBm.png&amp;amp;w=1412&amp;amp;h=444&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from IPython.display import Image, display


# Graph state
class State(TypedDict):
    topic: str
    joke: str
    improved_joke: str
    final_joke: str


# Nodes
def generate_joke(state: State):
    &quot;&quot;&quot;First LLM call to generate initial joke&quot;&quot;&quot;

    msg = llm.invoke(f&quot;Write a short joke about {state[&apos;topic&apos;]}&quot;)
    return {&quot;joke&quot;: msg.content}


def check_punchline(state: State):
    &quot;&quot;&quot;Gate function to check if the joke has a punchline&quot;&quot;&quot;

    # Simple check - does the joke contain &quot;?&quot; or &quot;!&quot;
    if &quot;?&quot; in state[&quot;joke&quot;] or &quot;!&quot; in state[&quot;joke&quot;]:
        return &quot;Pass&quot;
    return &quot;Fail&quot;


def improve_joke(state: State):
    &quot;&quot;&quot;Second LLM call to improve the joke&quot;&quot;&quot;

    msg = llm.invoke(f&quot;Make this joke funnier by adding wordplay: {state[&apos;joke&apos;]}&quot;)
    return {&quot;improved_joke&quot;: msg.content}


def polish_joke(state: State):
    &quot;&quot;&quot;Third LLM call for final polish&quot;&quot;&quot;
    msg = llm.invoke(f&quot;Add a surprising twist to this joke: {state[&apos;improved_joke&apos;]}&quot;)
    return {&quot;final_joke&quot;: msg.content}


# Build workflow
workflow = StateGraph(State)

# Add nodes
workflow.add_node(&quot;generate_joke&quot;, generate_joke)
workflow.add_node(&quot;improve_joke&quot;, improve_joke)
workflow.add_node(&quot;polish_joke&quot;, polish_joke)

# Add edges to connect nodes
workflow.add_edge(START, &quot;generate_joke&quot;)
workflow.add_conditional_edges(
    &quot;generate_joke&quot;, check_punchline, {&quot;Fail&quot;: &quot;improve_joke&quot;, &quot;Pass&quot;: END}
)
workflow.add_edge(&quot;improve_joke&quot;, &quot;polish_joke&quot;)
workflow.add_edge(&quot;polish_joke&quot;, END)

# Compile
chain = workflow.compile()

# Show workflow
display(Image(chain.get_graph().draw_mermaid_png()))

# Invoke
state = chain.invoke({&quot;topic&quot;: &quot;cats&quot;})
print(&quot;Initial joke:&quot;)
print(state[&quot;joke&quot;])
print(&quot;\n--- --- ---\n&quot;)
if &quot;improved_joke&quot; in state:
    print(&quot;Improved joke:&quot;)
    print(state[&quot;improved_joke&quot;])
    print(&quot;\n--- --- ---\n&quot;)

    print(&quot;Final joke:&quot;)
    print(state[&quot;final_joke&quot;])
else:
    print(&quot;Final joke:&quot;)
    print(state[&quot;joke&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A([START]) --&amp;gt; B[generate_joke&amp;lt;br/&amp;gt;根据 topic 生成初始 joke]

    B --&amp;gt; C{check_punchline&amp;lt;br/&amp;gt;joke 是否包含 ? 或 !}

    C -- Pass --&amp;gt; D([END&amp;lt;br/&amp;gt;直接输出 joke])
    C -- Fail --&amp;gt; E[improve_joke&amp;lt;br/&amp;gt;基于 joke 做改写]
    E --&amp;gt; F[polish_joke&amp;lt;br/&amp;gt;基于 improved_joke 再润色]
    F --&amp;gt; G([END&amp;lt;br/&amp;gt;输出 final_joke])

    B -.-&amp;gt; H[(state.joke)]
    E -.-&amp;gt; I[(state.improved_joke)]
    F -.-&amp;gt; J[(state.final_joke)]

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. Parallelization&lt;/h2&gt;
&lt;p&gt;借助并行化，大语言模型可同时处理一项任务。实现方式包括同时运行多个独立子任务，或多次运行同一任务以校验不同输出结果。并行化通常用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;拆分子任务并并行执行，从而提升处理速度&lt;/li&gt;
&lt;li&gt;多次运行任务以校验不同输出结果，从而提高结果可信度&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相关示例包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;运行一个子任务提取文档关键词，同时运行另一个子任务检查格式错误&lt;/li&gt;
&lt;li&gt;多次运行任务，依据不同标准（如引用数量、使用来源数量及来源质量）对文档准确性进行评分&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-14.DyVwmkTl.png&amp;amp;w=1020&amp;amp;h=684&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Graph state
class State(TypedDict):
    topic: str
    joke: str
    story: str
    poem: str
    combined_output: str


# Nodes
def call_llm_1(state: State):
    &quot;&quot;&quot;First LLM call to generate initial joke&quot;&quot;&quot;

    msg = llm.invoke(f&quot;Write a joke about {state[&apos;topic&apos;]}&quot;)
    return {&quot;joke&quot;: msg.content}


def call_llm_2(state: State):
    &quot;&quot;&quot;Second LLM call to generate story&quot;&quot;&quot;

    msg = llm.invoke(f&quot;Write a story about {state[&apos;topic&apos;]}&quot;)
    return {&quot;story&quot;: msg.content}


def call_llm_3(state: State):
    &quot;&quot;&quot;Third LLM call to generate poem&quot;&quot;&quot;

    msg = llm.invoke(f&quot;Write a poem about {state[&apos;topic&apos;]}&quot;)
    return {&quot;poem&quot;: msg.content}


def aggregator(state: State):
    &quot;&quot;&quot;Combine the joke, story and poem into a single output&quot;&quot;&quot;

    combined = f&quot;Here&apos;s a story, joke, and poem about {state[&apos;topic&apos;]}!\n\n&quot;
    combined += f&quot;STORY:\n{state[&apos;story&apos;]}\n\n&quot;
    combined += f&quot;JOKE:\n{state[&apos;joke&apos;]}\n\n&quot;
    combined += f&quot;POEM:\n{state[&apos;poem&apos;]}&quot;
    return {&quot;combined_output&quot;: combined}


# Build workflow
parallel_builder = StateGraph(State)

# Add nodes
parallel_builder.add_node(&quot;call_llm_1&quot;, call_llm_1)
parallel_builder.add_node(&quot;call_llm_2&quot;, call_llm_2)
parallel_builder.add_node(&quot;call_llm_3&quot;, call_llm_3)
parallel_builder.add_node(&quot;aggregator&quot;, aggregator)

# Add edges to connect nodes
parallel_builder.add_edge(START, &quot;call_llm_1&quot;)
parallel_builder.add_edge(START, &quot;call_llm_2&quot;)
parallel_builder.add_edge(START, &quot;call_llm_3&quot;)
parallel_builder.add_edge(&quot;call_llm_1&quot;, &quot;aggregator&quot;)
parallel_builder.add_edge(&quot;call_llm_2&quot;, &quot;aggregator&quot;)
parallel_builder.add_edge(&quot;call_llm_3&quot;, &quot;aggregator&quot;)
parallel_builder.add_edge(&quot;aggregator&quot;, END)
parallel_workflow = parallel_builder.compile()

# Show workflow
display(Image(parallel_workflow.get_graph().draw_mermaid_png()))

# Invoke
state = parallel_workflow.invoke({&quot;topic&quot;: &quot;cats&quot;})
print(state[&quot;combined_output&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A([START&amp;lt;br/&amp;gt;输入 topic]) --&amp;gt; B[call_llm_1&amp;lt;br/&amp;gt;生成 joke]
    A --&amp;gt; C[call_llm_2&amp;lt;br/&amp;gt;生成 story]
    A --&amp;gt; D[call_llm_3&amp;lt;br/&amp;gt;生成 poem]

    B --&amp;gt; E[aggregator&amp;lt;br/&amp;gt;汇总 joke / story / poem]
    C --&amp;gt; E
    D --&amp;gt; E

    E --&amp;gt; F([END&amp;lt;br/&amp;gt;输出 combined_output])

    B -.-&amp;gt; G[(state.joke)]
    C -.-&amp;gt; H[(state.story)]
    D -.-&amp;gt; I[(state.poem)]
    E -.-&amp;gt; J[(state.combined_output)]

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. Routing&lt;/h2&gt;
&lt;p&gt;路由工作流会处理输入内容，然后将其导向对应上下文的特定任务。这使你能够为复杂任务定义专用流程。例如，一个用于解答产品相关问题的工作流，可先处理问题类型，再将请求路由至定价、退款、退换货等专属处理流程。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-15.BeCUZsy7.png&amp;amp;w=1214&amp;amp;h=678&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import Literal
from langchain.messages import HumanMessage, SystemMessage


# Schema for structured output to use as routing logic
class Route(BaseModel):
    step: Literal[&quot;poem&quot;, &quot;story&quot;, &quot;joke&quot;] = Field(
        None, description=&quot;The next step in the routing process&quot;
    )


# Augment the LLM with schema for structured output
router = llm.with_structured_output(Route)


# State
class State(TypedDict):
    input: str
    decision: str
    output: str


# Nodes
def llm_call_1(state: State):
    &quot;&quot;&quot;Write a story&quot;&quot;&quot;

    result = llm.invoke(state[&quot;input&quot;])
    return {&quot;output&quot;: result.content}


def llm_call_2(state: State):
    &quot;&quot;&quot;Write a joke&quot;&quot;&quot;

    result = llm.invoke(state[&quot;input&quot;])
    return {&quot;output&quot;: result.content}


def llm_call_3(state: State):
    &quot;&quot;&quot;Write a poem&quot;&quot;&quot;

    result = llm.invoke(state[&quot;input&quot;])
    return {&quot;output&quot;: result.content}


def llm_call_router(state: State):
    &quot;&quot;&quot;Route the input to the appropriate node&quot;&quot;&quot;

    # Run the augmented LLM with structured output to serve as routing logic
    decision = router.invoke(
        [
            SystemMessage(
                content=&quot;Route the input to story, joke, or poem based on the user&apos;s request.&quot;
            ),
            HumanMessage(content=state[&quot;input&quot;]),
        ]
    )

    return {&quot;decision&quot;: decision.step}


# Conditional edge function to route to the appropriate node
def route_decision(state: State):
    # Return the node name you want to visit next
    if state[&quot;decision&quot;] == &quot;story&quot;:
        return &quot;llm_call_1&quot;
    elif state[&quot;decision&quot;] == &quot;joke&quot;:
        return &quot;llm_call_2&quot;
    elif state[&quot;decision&quot;] == &quot;poem&quot;:
        return &quot;llm_call_3&quot;


# Build workflow
router_builder = StateGraph(State)

# Add nodes
router_builder.add_node(&quot;llm_call_1&quot;, llm_call_1)
router_builder.add_node(&quot;llm_call_2&quot;, llm_call_2)
router_builder.add_node(&quot;llm_call_3&quot;, llm_call_3)
router_builder.add_node(&quot;llm_call_router&quot;, llm_call_router)

# Add edges to connect nodes
router_builder.add_edge(START, &quot;llm_call_router&quot;)
router_builder.add_conditional_edges(
    &quot;llm_call_router&quot;,
    route_decision,
    {  # Name returned by route_decision : Name of next node to visit
        &quot;llm_call_1&quot;: &quot;llm_call_1&quot;,
        &quot;llm_call_2&quot;: &quot;llm_call_2&quot;,
        &quot;llm_call_3&quot;: &quot;llm_call_3&quot;,
    },
)
router_builder.add_edge(&quot;llm_call_1&quot;, END)
router_builder.add_edge(&quot;llm_call_2&quot;, END)
router_builder.add_edge(&quot;llm_call_3&quot;, END)

# Compile workflow
router_workflow = router_builder.compile()

# Show the workflow
display(Image(router_workflow.get_graph().draw_mermaid_png()))

# Invoke
state = router_workflow.invoke({&quot;input&quot;: &quot;Write me a joke about cats&quot;})
print(state[&quot;output&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A([START&amp;lt;br/&amp;gt;输入 input]) --&amp;gt; B[llm_call_router&amp;lt;br/&amp;gt;LLM 进行结构化路由判断]

    B --&amp;gt; C{route_decision&amp;lt;br/&amp;gt;decision = story / joke / poem}

    C -- story --&amp;gt; D[llm_call_1&amp;lt;br/&amp;gt;生成 story]
    C -- joke --&amp;gt; E[llm_call_2&amp;lt;br/&amp;gt;生成 joke]
    C -- poem --&amp;gt; F[llm_call_3&amp;lt;br/&amp;gt;生成 poem]

    D --&amp;gt; G([END&amp;lt;br/&amp;gt;输出 output])
    E --&amp;gt; G
    F --&amp;gt; G

    B -.-&amp;gt; H[(state.decision)]
    D -.-&amp;gt; I[(state.output)]
    E -.-&amp;gt; I
    F -.-&amp;gt; I

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. Orchestrator-worker&lt;/h2&gt;
&lt;p&gt;在协调器 - 工作节点架构中，协调器负责：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;将任务拆解为子任务&lt;/li&gt;
&lt;li&gt;将子任务分配给工作节点执行&lt;/li&gt;
&lt;li&gt;整合各工作节点的输出结果形成最终成果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-16.DlNe118E.png&amp;amp;w=1486&amp;amp;h=548&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated, List
import operator


# Schema for structured output to use in planning
class Section(BaseModel):
    name: str = Field(
        description=&quot;Name for this section of the report.&quot;,
    )
    description: str = Field(
        description=&quot;Brief overview of the main topics and concepts to be covered in this section.&quot;,
    )


class Sections(BaseModel):
    sections: List[Section] = Field(
        description=&quot;Sections of the report.&quot;,
    )


# Augment the LLM with schema for structured output
planner = llm.with_structured_output(Sections)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;协调器 - 工作流模式十分常见，LangGraph 已内置对该模式的支持。Send API 可动态创建工作节点并向其发送指定输入。每个工作节点拥有独立状态，所有工作节点的输出都会写入一个共享状态键，协调器图可访问该键。这使得协调器能够获取所有工作节点的输出，并将其整合为最终输出。下面的示例会遍历章节列表，并通过 Send API 将每个章节分发给对应工作节点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.types import Send


# Graph state
class State(TypedDict):
    topic: str  # Report topic
    sections: list[Section]  # List of report sections
    completed_sections: Annotated[
        list, operator.add
    ]  # All workers write to this key in parallel
    final_report: str  # Final report


# Worker state
class WorkerState(TypedDict):
    section: Section
    completed_sections: Annotated[list, operator.add]


# Nodes
def orchestrator(state: State):
    &quot;&quot;&quot;Orchestrator that generates a plan for the report&quot;&quot;&quot;

    # Generate queries
    report_sections = planner.invoke(
        [
            SystemMessage(content=&quot;Generate a plan for the report.&quot;),
            HumanMessage(content=f&quot;Here is the report topic: {state[&apos;topic&apos;]}&quot;),
        ]
    )

    return {&quot;sections&quot;: report_sections.sections}


def llm_call(state: WorkerState):
    &quot;&quot;&quot;Worker writes a section of the report&quot;&quot;&quot;

    # Generate section
    section = llm.invoke(
        [
            SystemMessage(
                content=&quot;Write a report section following the provided name and description. Include no preamble for each section. Use markdown formatting.&quot;
            ),
            HumanMessage(
                content=f&quot;Here is the section name: {state[&apos;section&apos;].name} and description: {state[&apos;section&apos;].description}&quot;
            ),
        ]
    )

    # Write the updated section to completed sections
    return {&quot;completed_sections&quot;: [section.content]}


def synthesizer(state: State):
    &quot;&quot;&quot;Synthesize full report from sections&quot;&quot;&quot;

    # List of completed sections
    completed_sections = state[&quot;completed_sections&quot;]

    # Format completed section to str to use as context for final sections
    completed_report_sections = &quot;\n\n---\n\n&quot;.join(completed_sections)

    return {&quot;final_report&quot;: completed_report_sections}


# Conditional edge function to create llm_call workers that each write a section of the report
def assign_workers(state: State):
    &quot;&quot;&quot;Assign a worker to each section in the plan&quot;&quot;&quot;

    # Kick off section writing in parallel via Send() API
    return [Send(&quot;llm_call&quot;, {&quot;section&quot;: s}) for s in state[&quot;sections&quot;]]


# Build workflow
orchestrator_worker_builder = StateGraph(State)

# Add the nodes
orchestrator_worker_builder.add_node(&quot;orchestrator&quot;, orchestrator)
orchestrator_worker_builder.add_node(&quot;llm_call&quot;, llm_call)
orchestrator_worker_builder.add_node(&quot;synthesizer&quot;, synthesizer)

# Add edges to connect nodes
orchestrator_worker_builder.add_edge(START, &quot;orchestrator&quot;)
orchestrator_worker_builder.add_conditional_edges(
    &quot;orchestrator&quot;, assign_workers, [&quot;llm_call&quot;]
)
orchestrator_worker_builder.add_edge(&quot;llm_call&quot;, &quot;synthesizer&quot;)
orchestrator_worker_builder.add_edge(&quot;synthesizer&quot;, END)

# Compile the workflow
orchestrator_worker = orchestrator_worker_builder.compile()

# Show the workflow
display(Image(orchestrator_worker.get_graph().draw_mermaid_png()))

# Invoke
state = orchestrator_worker.invoke({&quot;topic&quot;: &quot;Create a report on LLM scaling laws&quot;})

from IPython.display import Markdown
Markdown(state[&quot;final_report&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A([START&amp;lt;br/&amp;gt;输入 topic]) --&amp;gt; B[orchestrator&amp;lt;br/&amp;gt;规划报告 sections]

    B --&amp;gt; C{assign_workers&amp;lt;br/&amp;gt;为每个 section 创建一个 worker}

    C --&amp;gt; D[llm_call Worker 1&amp;lt;br/&amp;gt;写 section 1]
    C --&amp;gt; E[llm_call Worker 2&amp;lt;br/&amp;gt;写 section 2]
    C --&amp;gt; F[llm_call Worker N&amp;lt;br/&amp;gt;写 section N]

    D --&amp;gt; G[synthesizer&amp;lt;br/&amp;gt;汇总 completed_sections]
    E --&amp;gt; G
    F --&amp;gt; G

    G --&amp;gt; H([END&amp;lt;br/&amp;gt;输出 final_report])

    B -.-&amp;gt; I[(state.sections)]
    D -.-&amp;gt; J[(state.completed_sections += section.content)]
    E -.-&amp;gt; J
    F -.-&amp;gt; J
    G -.-&amp;gt; K[(state.final_report)]

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. Evaluator-optimizer&lt;/h2&gt;
&lt;p&gt;在评估器 - 优化器工作流中，一个大语言模型生成响应，另一个则对该响应进行评估。若评估器或人工介入环节判定响应需要优化，系统会提供反馈并重新生成响应。该循环持续进行，直至生成符合要求的响应。&lt;/p&gt;
&lt;p&gt;评估器 - 优化器工作流常用于任务存在明确成功标准、但需通过迭代才能达标的场景。例如，两种语言间的文本翻译往往难以一次完美匹配，可能需要多次迭代，才能生成语义一致的译文。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-17.lgyNdQKZ.png&amp;amp;w=1004&amp;amp;h=340&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Graph state
class State(TypedDict):
    joke: str
    topic: str
    feedback: str
    funny_or_not: str


# Schema for structured output to use in evaluation
class Feedback(BaseModel):
    grade: Literal[&quot;funny&quot;, &quot;not funny&quot;] = Field(
        description=&quot;Decide if the joke is funny or not.&quot;,
    )
    feedback: str = Field(
        description=&quot;If the joke is not funny, provide feedback on how to improve it.&quot;,
    )


# Augment the LLM with schema for structured output
evaluator = llm.with_structured_output(Feedback)


# Nodes
def llm_call_generator(state: State):
    &quot;&quot;&quot;LLM generates a joke&quot;&quot;&quot;

    if state.get(&quot;feedback&quot;):
        msg = llm.invoke(
            f&quot;Write a joke about {state[&apos;topic&apos;]} but take into account the feedback: {state[&apos;feedback&apos;]}&quot;
        )
    else:
        msg = llm.invoke(f&quot;Write a joke about {state[&apos;topic&apos;]}&quot;)
    return {&quot;joke&quot;: msg.content}


def llm_call_evaluator(state: State):
    &quot;&quot;&quot;LLM evaluates the joke&quot;&quot;&quot;

    grade = evaluator.invoke(f&quot;Grade the joke {state[&apos;joke&apos;]}&quot;)
    return {&quot;funny_or_not&quot;: grade.grade, &quot;feedback&quot;: grade.feedback}


# Conditional edge function to route back to joke generator or end based upon feedback from the evaluator
def route_joke(state: State):
    &quot;&quot;&quot;Route back to joke generator or end based upon feedback from the evaluator&quot;&quot;&quot;

    if state[&quot;funny_or_not&quot;] == &quot;funny&quot;:
        return &quot;Accepted&quot;
    elif state[&quot;funny_or_not&quot;] == &quot;not funny&quot;:
        return &quot;Rejected + Feedback&quot;


# Build workflow
optimizer_builder = StateGraph(State)

# Add the nodes
optimizer_builder.add_node(&quot;llm_call_generator&quot;, llm_call_generator)
optimizer_builder.add_node(&quot;llm_call_evaluator&quot;, llm_call_evaluator)

# Add edges to connect nodes
optimizer_builder.add_edge(START, &quot;llm_call_generator&quot;)
optimizer_builder.add_edge(&quot;llm_call_generator&quot;, &quot;llm_call_evaluator&quot;)
optimizer_builder.add_conditional_edges(
    &quot;llm_call_evaluator&quot;,
    route_joke,
    {  # Name returned by route_joke : Name of next node to visit
        &quot;Accepted&quot;: END,
        &quot;Rejected + Feedback&quot;: &quot;llm_call_generator&quot;,
    },
)

# Compile the workflow
optimizer_workflow = optimizer_builder.compile()

# Show the workflow
display(Image(optimizer_workflow.get_graph().draw_mermaid_png()))

# Invoke
state = optimizer_workflow.invoke({&quot;topic&quot;: &quot;Cats&quot;})
print(state[&quot;joke&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A([START&amp;lt;br/&amp;gt;输入 topic]) --&amp;gt; B[llm_call_generator&amp;lt;br/&amp;gt;生成 joke]

    B --&amp;gt; C[llm_call_evaluator&amp;lt;br/&amp;gt;评价 joke 并给出 feedback]

    C --&amp;gt; D{route_joke&amp;lt;br/&amp;gt;funny_or_not?}

    D -- Accepted --&amp;gt; E([END&amp;lt;br/&amp;gt;输出 joke])
    D -- Rejected + Feedback --&amp;gt; B

    B -.-&amp;gt; F[(state.joke)]
    C -.-&amp;gt; G[(state.funny_or_not)]
    C -.-&amp;gt; H[(state.feedback)]

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. Agents&lt;/h2&gt;
&lt;p&gt;智能体通常由大语言模型实现，通过工具执行操作。它们在持续的反馈循环中运行，适用于问题与解决方案均不可预测的场景。智能体比工作流具有更高的自主性，能够自主决定使用何种工具以及如何解决问题。你仍可定义可用的工具集及智能体的行为准则。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-18.BT8MSJFr.png&amp;amp;w=1732&amp;amp;h=712&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.graph import MessagesState
from langchain.messages import SystemMessage, HumanMessage, ToolMessage


# Nodes
def llm_call(state: MessagesState):
    &quot;&quot;&quot;LLM decides whether to call a tool or not&quot;&quot;&quot;

    return {
        &quot;messages&quot;: [
            llm_with_tools.invoke(
                [
                    SystemMessage(
                        content=&quot;You are a helpful assistant tasked with performing arithmetic on a set of inputs.&quot;
                    )
                ]
                + state[&quot;messages&quot;]
            )
        ]
    }


def tool_node(state: dict):
    &quot;&quot;&quot;Performs the tool call&quot;&quot;&quot;

    result = []
    for tool_call in state[&quot;messages&quot;][-1].tool_calls:
        tool = tools_by_name[tool_call[&quot;name&quot;]]
        observation = tool.invoke(tool_call[&quot;args&quot;])
        result.append(ToolMessage(content=observation, tool_call_id=tool_call[&quot;id&quot;]))
    return {&quot;messages&quot;: result}


# Conditional edge function to route to the tool node or end based upon whether the LLM made a tool call
def should_continue(state: MessagesState) -&amp;gt; Literal[&quot;tool_node&quot;, END]:
    &quot;&quot;&quot;Decide if we should continue the loop or stop based upon whether the LLM made a tool call&quot;&quot;&quot;

    messages = state[&quot;messages&quot;]
    last_message = messages[-1]

    # If the LLM makes a tool call, then perform an action
    if last_message.tool_calls:
        return &quot;tool_node&quot;

    # Otherwise, we stop (reply to the user)
    return END


# Build workflow
agent_builder = StateGraph(MessagesState)

# Add nodes
agent_builder.add_node(&quot;llm_call&quot;, llm_call)
agent_builder.add_node(&quot;tool_node&quot;, tool_node)

# Add edges to connect nodes
agent_builder.add_edge(START, &quot;llm_call&quot;)
agent_builder.add_conditional_edges(
    &quot;llm_call&quot;,
    should_continue,
    [&quot;tool_node&quot;, END]
)
agent_builder.add_edge(&quot;tool_node&quot;, &quot;llm_call&quot;)

# Compile the agent
agent = agent_builder.compile()

# Show the agent
display(Image(agent.get_graph(xray=True).draw_mermaid_png()))

# Invoke
messages = [HumanMessage(content=&quot;Add 3 and 4.&quot;)]
messages = agent.invoke({&quot;messages&quot;: messages})
for m in messages[&quot;messages&quot;]:
    m.pretty_print()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A([START&amp;lt;br/&amp;gt;输入 messages]) --&amp;gt; B[llm_call&amp;lt;br/&amp;gt;LLM 决定直接回答还是调用工具]

    B --&amp;gt; C{should_continue&amp;lt;br/&amp;gt;last_message.tool_calls ?}

    C -- Yes --&amp;gt; D[tool_node&amp;lt;br/&amp;gt;执行工具并生成 ToolMessage]
    D --&amp;gt; B

    C -- No --&amp;gt; E([END&amp;lt;br/&amp;gt;输出最终 AIMessage])

    B -.-&amp;gt; F[(AIMessage&amp;lt;br/&amp;gt;可能包含 tool_calls)]
    D -.-&amp;gt; G[(ToolMessage&amp;lt;br/&amp;gt;工具执行结果)]
    E -.-&amp;gt; H[(messages 完整对话历史)]

&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>PyTorch RNN：序列建模与 LSTM 入门</title><link>https://owen571.top/posts/study/pytorch/06-pytorch-rnn-%E4%B8%8E%E5%BA%8F%E5%88%97%E5%BB%BA%E6%A8%A1%E5%85%A5%E9%97%A8/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/06-pytorch-rnn-%E4%B8%8E%E5%BA%8F%E5%88%97%E5%BB%BA%E6%A8%A1%E5%85%A5%E9%97%A8/</guid><description>从为什么需要序列模型讲起，把 one-hot、embedding、RNN、LSTM 和一个名字-国家分类任务串起来。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇主要整理自 &lt;code&gt;liuer_pytorch/14-15.ipynb&lt;/code&gt;。在前面几篇里，输入大多可以看成“彼此独立的特征向量”；到了序列建模，这个假设就不成立了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 为什么会需要 RNN&lt;/h2&gt;
&lt;p&gt;课程里给出的切入点很朴素：&lt;br /&gt;
如果要根据前几天的天气预测今天的天气，把所有天直接拼成一个长向量喂进全连接层当然可以，但参数会很大，也不自然。&lt;/p&gt;
&lt;p&gt;序列任务更在意的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前后顺序&lt;/li&gt;
&lt;li&gt;上下文依赖&lt;/li&gt;
&lt;li&gt;可变长度&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就是 RNN 这类模型出现的原因。&lt;/p&gt;
&lt;h2&gt;2. RNN 的最小心智&lt;/h2&gt;
&lt;p&gt;我自己记 RNN，不是先记公式，而是先记这件事：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;RNN 不是把整段序列一次性塞进一个全连接层，而是让同一个 Cell 沿时间步重复处理信息。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;所以图里那些同色的 &lt;code&gt;RNN Cell&lt;/code&gt;，本质上共享的是同一组参数。&lt;br /&gt;
时间在往前走，隐藏状态在传递。&lt;/p&gt;
&lt;h2&gt;3. one-hot 的问题，和 embedding 为什么重要&lt;/h2&gt;
&lt;p&gt;课程在字符级示例里先用了 one-hot 编码，比如学 &lt;code&gt;&quot;hello&quot; -&amp;gt; &quot;ohlol&quot;&lt;/code&gt; 这种简单序列。&lt;br /&gt;
然后很快指出 one-hot 的三个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;高维（high-dimension）&lt;/li&gt;
&lt;li&gt;稀疏（sparse）&lt;/li&gt;
&lt;li&gt;硬编码（hardcoded）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这正好引出 embedding。&lt;/p&gt;
&lt;p&gt;embedding 的意义不是“把离散 token 变成稠密向量”这么简单，&lt;br /&gt;
更关键的是：它允许模型去学习“词和词之间的相对关系”。&lt;/p&gt;
&lt;h2&gt;4. LSTM 是在解决什么&lt;/h2&gt;
&lt;p&gt;当序列变长，普通 RNN 很容易碰到长期依赖问题。&lt;br /&gt;
课程在这一节里把 LSTM 作为升级版介绍，我觉得最重要的不是立刻背门结构，而是先知道：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普通 RNN 容易遗忘长距离信息&lt;/li&gt;
&lt;li&gt;LSTM 引入了更强的记忆与控制机制&lt;/li&gt;
&lt;li&gt;它的目的就是让信息保存和遗忘变得更可控&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. 一个完整的小任务：名字-国家分类&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;15.ipynb&lt;/code&gt; 里，这个任务很适合做入门示例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;输入是一串字符&lt;/li&gt;
&lt;li&gt;输出是一个国家类别&lt;/li&gt;
&lt;li&gt;它不是每个时间步都分类，而是整段序列最终分类&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以课程里特别提醒了一点：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;因为任务是一句话/一段话之分一个类别，而不是每个词都分一个类别，所以不用保留每个时间步的 outputs，而是使用最终状态 hidden。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这句话非常重要。它让我们知道：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;序列任务不一定都是 token-level 任务&lt;/li&gt;
&lt;li&gt;有些任务只关心最终整体表示&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. 学 RNN 阶段别急着追复杂架构&lt;/h2&gt;
&lt;p&gt;我现在回头看，这一阶段最应该建立的是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;为什么序列不能像普通表格特征那样粗暴拼接&lt;/li&gt;
&lt;li&gt;隐状态在序列传播里扮演什么角色&lt;/li&gt;
&lt;li&gt;one-hot 和 embedding 的区别&lt;/li&gt;
&lt;li&gt;什么时候该取每个时间步输出，什么时候只看最终 hidden&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果这些没通，后面看 GRU、Attention、Transformer 也会很容易失去主线。&lt;/p&gt;
&lt;h2&gt;7. 这一阶段该记住什么&lt;/h2&gt;
&lt;p&gt;如果只留最核心的几句：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;RNN 处理的是有顺序依赖的数据。&lt;/li&gt;
&lt;li&gt;同一个 RNN Cell 会沿时间步重复使用。&lt;/li&gt;
&lt;li&gt;one-hot 是起点，但 embedding 更适合真实任务。&lt;/li&gt;
&lt;li&gt;LSTM 是在解决长期依赖和信息保留问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;有了这些直觉，再去看更现代的序列模型，就会顺很多。&lt;/p&gt;
</content:encoded></item><item><title>RAG 索引基础：向量嵌入、相似度与向量数据库</title><link>https://owen571.top/posts/study/rag/04-rag-%E5%90%91%E9%87%8F%E5%B5%8C%E5%85%A5%E4%B8%8E%E5%90%91%E9%87%8F%E6%95%B0%E6%8D%AE%E5%BA%93%E5%9F%BA%E7%A1%80/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/04-rag-%E5%90%91%E9%87%8F%E5%B5%8C%E5%85%A5%E4%B8%8E%E5%90%91%E9%87%8F%E6%95%B0%E6%8D%AE%E5%BA%93%E5%9F%BA%E7%A1%80/</guid><description>从向量嵌入讲到相似度度量与向量数据库，把 RAG 检索层最关键的基础概念连成一条线。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇开始真正进入 RAG 的“索引层”。如果说前面两篇是在准备语料，那么这里就是在回答：这些文本为什么能被表示成向量，又为什么可以被高效检索出来。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 索引构建&lt;/h1&gt;
&lt;h2&gt;一、向量嵌入&lt;/h2&gt;
&lt;h2&gt;1. 向量嵌入基础&lt;/h2&gt;
&lt;p&gt;上一章语义分块的时候就用到了语义嵌入模型，简单介绍了一下嵌入的过程。实际上，准确来说向量嵌入（Embedding）是一种将真实世界中复杂、高维的数据对象（如文本、图像、音频、视频等）转换为数学上易于处理的、低维、稠密的连续数值向量的技术。
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-8.DdY6bH1x.png&amp;amp;w=1358&amp;amp;h=569&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Embedding 的真正意义在于，它产生的向量不是随机数值的堆砌，而是对数据语义的数学编码。在 Embedding 构建的向量空间中，语义上相似的对象，其对应的向量在空间中的距离会更近；而语义上不相关的对象，它们的向量距离会更远。&lt;/p&gt;
&lt;p&gt;我们用以下方式来衡量向量之间的距离（相似度）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;度量方式&lt;/th&gt;
&lt;th&gt;核心含义&lt;/th&gt;
&lt;th&gt;优点&lt;/th&gt;
&lt;th&gt;缺点&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;余弦相似度（Cosine Similarity）&lt;/td&gt;
&lt;td&gt;衡量两个向量夹角的余弦值，关注方向是否一致，而不太关注向量长度&lt;/td&gt;
&lt;td&gt;对向量长度不敏感，能更好反映语义方向上的相似性；在文本检索和语义搜索中最常用&lt;/td&gt;
&lt;td&gt;忽略了向量模长所携带的信息；如果模型特意利用向量长度编码重要性，余弦相似度可能损失这部分信息&lt;/td&gt;
&lt;td&gt;语义检索、文本相似度计算、RAG 向量召回的主流选择&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;点积（Dot Product）&lt;/td&gt;
&lt;td&gt;计算两个向量对应维度乘积之和，同时受方向和模长影响&lt;/td&gt;
&lt;td&gt;计算高效；当向量已归一化时，与余弦相似度等价；适合大规模向量检索实现&lt;/td&gt;
&lt;td&gt;若向量未归一化，结果会受到长度影响，可能把“向量更长”误当成“更相似”&lt;/td&gt;
&lt;td&gt;向量已归一化的检索系统；高性能近似最近邻搜索；深度学习训练中的相似度计算&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;欧氏距离（Euclidean Distance）&lt;/td&gt;
&lt;td&gt;衡量两个向量在空间中的直线距离，距离越小表示越接近&lt;/td&gt;
&lt;td&gt;几何意义直观，容易理解；适合确实关心“空间位置差异”的任务&lt;/td&gt;
&lt;td&gt;对向量尺度敏感；在高维空间中区分度可能下降；文本语义检索中通常不如余弦相似度稳定&lt;/td&gt;
&lt;td&gt;低维空间分析、聚类任务、对空间距离本身有明确意义的场景&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;2. Embedding在RAG中的作用&lt;/h2&gt;
&lt;p&gt;RAG 的“检索”环节通常以基于 Embedding 的语义搜索为核心。通用流程如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;离线索引构建：将知识库内文档切分后，使用 Embedding 模型将每个文档块（Chunk）转换为向量，存入专门的向量数据库中。&lt;/li&gt;
&lt;li&gt;在线查询检索：当用户提出问题时，使用同一个 Embedding 模型将用户的问题也转换为一个向量。&lt;/li&gt;
&lt;li&gt;相似度计算：在向量数据库中，计算“问题向量”与所有“文档块向量”的相似度。&lt;/li&gt;
&lt;li&gt;召回上下文：选取相似度最高的 Top-K 个文档块，作为补充的上下文信息，与原始问题一同送给大语言模型（LLM）生成最终答案。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Embedding 的质量直接决定了 RAG 检索召回内容的准确性与相关性。一个优秀的 Embedding 模型能够精准捕捉问题和文档之间的深层语义联系，即使用户的提问和原文的表述不完全一致。&lt;/p&gt;
&lt;h2&gt;3. Embedding技术&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;注：由于目前主要关注RAG，本章略写&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;Word2Vec -&amp;gt; 动态嵌入 -&amp;gt; 更高要求。&lt;/li&gt;
&lt;li&gt;主要训练：自监督训练。主流嵌入模型是BERT的变体，所以详细可以看BERT的训练，也就是MLM和NSP那边。&lt;/li&gt;
&lt;li&gt;除了原本的训练，还会引入增强效果的训练，比如度量学习、对比学习等。&lt;/li&gt;
&lt;li&gt;选择嵌入模型，我们可以从&lt;a href=&quot;https://huggingface.co/spaces/mteb/leaderboard&quot;&gt;MTEB (Massive Text Embedding Benchmark) &lt;/a&gt;入手，是一个由 Hugging Face 维护的、全面的文本嵌入模型评测基准。它涵盖了分类、聚类、检索、排序等多种任务，并提供了公开的排行榜，为评估和选择嵌入模型提供了重要的参考依据。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;针对RAG而言，要格外注意以下维度：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任务 (Task) ：对于 RAG 应用，需要重点关注模型在 Retrieval (检索) 任务下的排名。&lt;/li&gt;
&lt;li&gt;语言 (Language) ：模型是否支持你的业务数据所使用的语言？对于中文 RAG，应选择明确支持中文或多语言的模型。&lt;/li&gt;
&lt;li&gt;模型大小 (Size) ：模型越大，通常性能越好，但对硬件（显存）的要求也越高，推理速度也越慢。需要根据你的部署环境和性能要求来权衡。&lt;/li&gt;
&lt;li&gt;维度 (Dimensions) ：向量维度越高，能编码的信息越丰富，但也会占用更多的存储空间和计算资源。&lt;/li&gt;
&lt;li&gt;最大 Token 数 (Max Tokens) ：这决定了模型能处理的文本长度上限。这个参数是你设计文本分块（Chunking）策略时必须考虑的重要依据，块大小不应超过此限制。&lt;/li&gt;
&lt;li&gt;得分与机构 (Score &amp;amp; Publisher) ：结合模型的得分排名和其发布机构的声誉进行初步筛选。知名机构发布的模型通常质量更有保障。&lt;/li&gt;
&lt;li&gt;成本 (Cost) ：如果是使用 API 服务的模型，需要考虑其调用成本；如果是自部署开源模型，则需要评估其对硬件资源的消耗（如显存、内存）以及带来的运维成本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当然，我们一般会用基线测试上面几个维度，然后构建私有测评集，迭代优化，选出该场景下最合适的模型。&lt;/p&gt;
&lt;h2&gt;二、多模态嵌入&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;偏科普，可跳&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;现代 AI 的一项重要突破，是将简单的词向量发展成了能统一理解图文、音视频的复杂系统。这一发展建立在注意力机制、Transformer 架构和对比学习等关键技术之上，它们解决了在共享向量空间中对齐不同数据模态的核心挑战。其发展环环相扣：Word2Vec 为 BERT 的上下文理解铺路，而 BERT 又为 CLIP 等模型的跨模态能力奠定了基础……&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-10.CPvPdbgI.png&amp;amp;w=1591&amp;amp;h=586&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;反正知道最终多模态信息也能被嵌入成高维稠密向量就行了。&lt;/p&gt;
&lt;h2&gt;三、向量数据库&lt;/h2&gt;
&lt;p&gt;在前面我们学习了如何使用嵌入模型将文本、图像等非结构化数据转换为高维向量。这些向量是 RAG 系统能够进行语义理解的基础。然而，当向量数量从几百个增长到数百万甚至数十亿时，一个核心问题随之而来：如何快速、准确地从海量向量中找到与用户查询最相似的那几个？&lt;/p&gt;
&lt;h2&gt;1. 向量数据库的功能&lt;/h2&gt;
&lt;p&gt;向量数据库的核心价值在于其高效处理海量高维向量的能力。其主要功能可以概括为以下几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;高效的相似性搜索：这是向量数据库最重要的功能。它利用专门的索引技术（如 HNSW, IVF），能够在数十亿级别的向量中实现毫秒级的近似最近邻（ANN）查询，快速找到与给定查询最相似的数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;高维数据存储与管理：专门为存储高维向量（通常维度成百上千）而优化，支持对向量数据进行增、删、改、查等基本操作。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;丰富的查询能力：除了基本的相似性搜索，还支持按标量字段过滤查询（例如，在搜索相似图片的同时，指定年份 &amp;gt; 2023）、范围查询和聚类分析等，满足复杂业务需求。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可扩展与高可用：现代向量数据库通常采用分布式架构，具备良好的水平扩展能力和容错性，能够通过增加节点来应对数据量的增长，并确保服务的稳定可靠。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据与模型生态集成：与主流的 AI 框架（如 LangChain, LlamaIndex）和机器学习工作流无缝集成，简化了从模型训练到向量检索的应用开发流程。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 向量数据库 vs 传统数据库&lt;/h2&gt;
&lt;p&gt;传统的数据库（如 MySQL）擅长处理结构化数据的精确匹配查询（例如，WHERE age = 25），但它们并非为处理高维向量的相似性搜索而设计的。在庞大的向量集合中进行暴力、线性的相似度计算，其计算成本和时间延迟无法接受。向量数据库 (Vector Database) 很好的解决了这一问题，它是一种专门设计用于高效存储、管理和查询高维向量的数据库系统。在 RAG 流程中，它扮演着“知识库”的角色，是连接数据与大语言模型的关键桥梁。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;向量数据库&lt;/th&gt;
&lt;th&gt;传统数据库（RDBMS）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心数据类型&lt;/td&gt;
&lt;td&gt;高维向量（Embeddings）&lt;/td&gt;
&lt;td&gt;结构化数据（文本、数字、日期）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查询方式&lt;/td&gt;
&lt;td&gt;相似性搜索（ANN）&lt;/td&gt;
&lt;td&gt;精确匹配&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;索引机制&lt;/td&gt;
&lt;td&gt;HNSW、IVF、LSH 等 ANN 索引&lt;/td&gt;
&lt;td&gt;B-Tree、Hash Index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主要应用场景&lt;/td&gt;
&lt;td&gt;AI 应用、RAG、推荐系统、图像 / 语音识别&lt;/td&gt;
&lt;td&gt;业务系统（ERP、CRM）、金融交易、数据报表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据规模&lt;/td&gt;
&lt;td&gt;轻松应对千亿级向量&lt;/td&gt;
&lt;td&gt;通常在千万到亿级行数据，更大规模需复杂分库分表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;性能特点&lt;/td&gt;
&lt;td&gt;高维数据检索性能极高，计算密集型&lt;/td&gt;
&lt;td&gt;结构化数据查询快，高维数据查询性能呈指数级下降&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;一致性&lt;/td&gt;
&lt;td&gt;通常为最终一致性&lt;/td&gt;
&lt;td&gt;强一致性（ACID 事务）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;向量数据库的核心是高效处理高维向量的相似性搜索。向量是一组有序的数值，可以表示文本、图像、音频等复杂数据的特征或属性。在 RAG 系统中，向量一般通过嵌入模型将原始数据转换为高维向量表示，比如上一节的图文示例。向量数据库通常采用四层架构，通过存储层、索引层、查询层和服务层的协同工作来实现高效相似性搜索，其中存储层负责存储向量数据和元数据，优化存储效率并支持分布式存储；索引层维护索引算法（HNSW、LSH、PQ等），负责索引的创建与优化，并支持索引调整；查询层处理查询请求，支持混合查询并实现查询优化；服务层管理客户端连接，提供监控和日志能力，并实现安全管理。&lt;/p&gt;
&lt;p&gt;主要技术手段包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;基于树的方法：如 Annoy 使用的随机投影树，通过树形结构实现对数复杂度的搜索&lt;/li&gt;
&lt;li&gt;基于哈希的方法：如 LSH（局部敏感哈希），通过哈希函数将相似向量映射到同一“桶”&lt;/li&gt;
&lt;li&gt;基于图的方法：如 HNSW（分层可导航小世界图），通过多层邻近图结构实现快速搜索&lt;/li&gt;
&lt;li&gt;基于量化的方法：如 Faiss 的 IVF 和 PQ，通过聚类和量化压缩向量&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 主流数据库介绍&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-11.CyizIBhU.png&amp;amp;w=1100&amp;amp;h=655&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;四、FAISS尝试&lt;/h2&gt;
&lt;p&gt;尝试利用 LangChain 和 FAISS 完成一个完整的“创建 -&amp;gt; 保存 -&amp;gt; 加载 -&amp;gt; 查询”流程。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_core.documents import Document

# 1. 示例文本和嵌入模型
texts = [
    &quot;张三是法外狂徒&quot;,
    &quot;FAISS是一个用于高效相似性搜索和密集向量聚类的库。&quot;,
    &quot;LangChain是一个用于开发由语言模型驱动的应用程序的框架。&quot;
]
docs = [Document(page_content=t) for t in texts]
embeddings = HuggingFaceEmbeddings(model_name=&quot;BAAI/bge-small-zh-v1.5&quot;)

# 2. 创建向量存储并保存到本地
vectorstore = FAISS.from_documents(docs, embeddings)

local_faiss_path = &quot;./faiss_index_store&quot;
vectorstore.save_local(local_faiss_path)

print(f&quot;FAISS index has been saved to {local_faiss_path}&quot;)

# 3. 加载索引并执行查询
# 加载时需指定相同的嵌入模型，并允许反序列化
loaded_vectorstore = FAISS.load_local(
    local_faiss_path,
    embeddings,
    allow_dangerous_deserialization=True
)

# 相似性搜索
query = &quot;FAISS是做什么的？&quot;
results = loaded_vectorstore.similarity_search(query, k=1)

print(f&quot;\n查询: &apos;{query}&apos;&quot;)
print(&quot;相似度最高的文档:&quot;)
for doc in results:
    print(f&quot;- {doc.page_content}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-12.DIWHMElZ.png&amp;amp;w=958&amp;amp;h=220&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>LLM 对齐训练：RLHF、奖励模型与规则化分支</title><link>https://owen571.top/posts/study/reinforce-learning/06-rlhf-%E5%A5%96%E5%8A%B1%E6%A8%A1%E5%9E%8B-%E4%B8%8E%E8%A7%84%E5%88%99%E5%8C%96%E5%88%86%E6%94%AF/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/06-rlhf-%E5%A5%96%E5%8A%B1%E6%A8%A1%E5%9E%8B-%E4%B8%8E%E8%A7%84%E5%88%99%E5%8C%96%E5%88%86%E6%94%AF/</guid><description>从 RLHF 基础流程出发，串起奖励模型、PPO 在 LLM 中的应用，以及 Constitutional AI 等分支。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;从学习笔记一到四, 我们从强化学习的引入基础开始, 慢慢往前推进, 终于是走到了当代算法的门口. 其实, PPO算法很多时候都是初学者的第一选择, 但是我们为了更好理解其来源, 还是从源头进行了学习. 不过, 接下来才是LLM+RL梦开始的地方, 真正将强化学习应用于大模型.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. LLM对齐家族&lt;/h1&gt;
&lt;p&gt;在笔记(一)中, 我们就曾经讨论了SFT的对齐鸿沟, 但是开始那些离散的、有限的强化学习方法, 完全没有办法处理我们对齐LLM的需求, 无论是DP, MC还是TD, 又或者后来的DQN, 基于Policy的Reinforce算法 ... 这时, 有一个非常重要的事情 -- TRPO的提出, 它通过目标函数平衡了策略更新和KL散度之间的关系, 确保即使在不可能的更新方向上, 策略性能依然提升. 从这之后, 这一领域各种研究接踵而至.&lt;/p&gt;
&lt;p&gt;PPO基于TRPO的思想, 但是用简单的clip函数进行暴力直接限制, 就达到了和GRPO相同的性能, 实际应用中完全超越TRPO. 这种&lt;strong&gt;稳定更新+可控偏移&lt;/strong&gt;的思想启发了很多研究者, 在工程化的思想上, &lt;strong&gt;将强化学习中RL最优解思想, 转化为大模型与人类对齐的优化框架&lt;/strong&gt;, 开启了整个LLM对齐家族的演化历程.&lt;/p&gt;
&lt;p&gt;我们可以现在这里简单描述一下, 图片比较好, 这里就再放了一次:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;DPO: 绕过了PPO的显式奖励建模, 直接将人类偏好信号融入策略目标的对齐方法. 它简化了RLHF pipeline, 是近期的重要进展. 后面都是对其的简化和改进.&lt;/li&gt;
&lt;li&gt;ReMax (REINFORCE+argmax)在REINFORCE基础上加个baseline, 放弃critic函数; GRPO (Group Relative Policy Optimiztion) 完全摒弃了价值网络, 通过组内相对奖励来估计优势函数; DAPO则这对GRPO进行改进...&lt;/li&gt;
&lt;li&gt;RLAIF: 对齐技术路径, 是RLHF的变体, 用AI反馈来代替人类反馈训练奖励模型. Constitutional AI则是RLAIF的具体实现和拓展.&lt;/li&gt;
&lt;li&gt;KTO: 在DPO基础上进一步优化, 引入效用函数...&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251103215651.B5vtDg3j.png&amp;amp;w=1204&amp;amp;h=1022&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;二. 基于人类反馈的强化学习 (Reinforcement Learning from Human Feedback, RLHF)&lt;/h1&gt;
&lt;p&gt;回忆我们在笔记(一)中讨论的第一个鸿沟, 其实就是基于大语言模型来说的. LLM基于互联网上的大量文本数据训练, 而这其中不有很多不好的数据, 比如Toxic language, Aggressive responses, Providing dangerous information等. 并且LLM通过SFT无法完全理解深层含义, 容易给出意想之外的回答. 我们将这些问题进行一下总结, 就可以发现, 我们期望模型/Agent应该是要符合人类价值观的. &lt;strong&gt;这些重要人类价值观, 有用(Helpful), 诚实(Honest)和无害(Harmless), 有时统称HHH&lt;/strong&gt;. 现在我们举例一些模型表现不好的情况 (注, knock, knock实际上是一个游戏, LLM没有分辨出深层含义):&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251103223548.C0dyhmth.png&amp;amp;w=1558&amp;amp;h=670&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;1. 最优解问题到与人类对齐&lt;/h2&gt;
&lt;p&gt;早在2020年, OpenAI的研究人员就发表过一篇论文(Fine-tuning with human feedback), 探讨了如何通过人类反馈进行微调, 训练模型编写文本文章的简短摘要. 结果发现人类反馈微调的模型的反应优于预训练模型和指令微调模型, 甚至超过了人类基准水平.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251103224751.D2UxBiAj.png&amp;amp;w=1688&amp;amp;h=776&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这种用人类反馈微调语言模型的流行技术, 被称为基于人类反馈的强化学习, 或简称RLHF. 这种技术的令人振奋之处, 在于可以对不同场景的LLMs进行个性化定制, 让模型通过持续的反馈过程学习每个用户的偏好.&lt;/p&gt;
&lt;p&gt;下图, 指导智能体动作的策略是LLM; 最终目标是生成被认为与人类偏好相符合的文本; 环境是模型的上下文窗口, 可以通过提示在其中输入文本的空间; 动作是生成文本的行为, 动作空间是Token词汇, 模型可以选择生成输出结果的所有可能Token (LLM决定生成序列中的下个token取决于它在训练期间学到的语言的统计表示; 模型采取的行动也就是选择哪个token, 取决于上下文的提示文本和词汇空间的概率分布).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251103231544.BGqbNzmh.png&amp;amp;w=1586&amp;amp;h=664&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;但是, 获取人类反馈可能既耗时又昂贵, 作为一个实用和可替代的方案, 可以使用一个额外的模型, 被称为&lt;strong&gt;奖励模型 (Reward Model, RM)&lt;/strong&gt;, 来分类LLM输出并评估与人类偏好的对齐程度.  我们把人类对于文本的反馈作为标量 (比如是否有毒 ),  可以从少量的人类示例开始, 用传统监督学习方式训练次级模型. 一旦训练完成, 就可以用RM来评估LLM的输出并分配奖励值, 这个奖励值友反过来更新LLM的权重, 训练出一个新的符合人类偏好的版本. 至于模型输出结果评估时权重如何更新, 取决于用于优化策略的算法.&lt;/p&gt;
&lt;p&gt;这里, 行动和状态的序列被称为展开 (Rollout), 而不是在经典强化学习中使用的playout. (虽说我貌似之前也就用的rollout没做区分...)&lt;/p&gt;
&lt;h2&gt;2. 奖励模型 (Reward Model)&lt;/h2&gt;
&lt;p&gt;首先, 我们要准备一个数据集, 包含了人类的反馈, 这是基础. 我们用一个提示集数据库, 用LLM针对每一个sample生成一个Model Completions.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251106132925.CzbL4zxa.png&amp;amp;w=1816&amp;amp;h=716&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;下一步, 我们需要收集人类标注员对LLM生成结果的反馈. 在收集反馈的过程中, 有以下两步:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;定义模型对齐的标准. 比如上述提到的, HHH准则, 例如帮助性和毒性&lt;/li&gt;
&lt;li&gt;根据对齐标准, 要求标注员对数据集中的生成结果进行标注&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如下图我们以Helpful为例子, 让人工标注了排序. 其中1最有用, 3最无关.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251106133504.Cc4YQv_c.png&amp;amp;w=1748&amp;amp;h=524&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在这之后, 标注员根据“提示词-生成结果”数据集的记录逐一重复这个过程, 从而建立一个可以用来训练&lt;strong&gt;奖励模型&lt;/strong&gt;的数据集, 最终代替人类完成这项工作.&lt;/p&gt;
&lt;p&gt;“提示词-生成效果”数据集通常会分配给多个人类标注员, 以确保大家的答案更一致, 降低某个人标记不准确的风险.&lt;/p&gt;
&lt;p&gt;另外, 指令越清晰, 得到的反馈就越清晰, 我们为人类标注员编写指示示例. 如下图:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251106134510.C2WvJLlt.png&amp;amp;w=1102&amp;amp;h=704&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;选择标注员时也会倾向于选择那些能代表多元文化和全球观点的人, 在标注前让他们阅读这些内容并且工作中随时参考.&lt;/p&gt;
&lt;p&gt;在这之后, 我们根据排名的数据, 转化成两两对比的数据集, 将它们标记为0分或1分, 作为奖励模型的训练数据, 所以会产生N选2组合的数据集, 然后排序让1分的在前面 (奖励模型期待首选的生成结果$y_j$ , 注意这里的1和0是为了排序, 而不是绝对的奖励值).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251106141558.BgzE3Xxd.png&amp;amp;w=1830&amp;amp;h=374&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;一个奖励模型, 通常也是一个语言模型. 对于给定的提示词, 奖励模型一般会按照如下过程:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: \[提示(prompt) + 完成(completion)] → 语言模型编码器 → 标量输出层 → 奖励分数
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而我们训练的时候, 会输入共用promt的两个completion (yj和yk), 得到奖励rj和rk, 由于我们的&lt;strong&gt;最终目标是要RM鼓励rj&amp;gt;rk这样的排序&lt;/strong&gt;, 我们需要一个概率模型来表示yj优于yk的可能性, 再去提高它. 这里非常常用的模型就是&lt;strong&gt;Bradley-Terry模型&lt;/strong&gt;. 它可以说是&lt;strong&gt;成对比较(Paired Comparisons)&lt;/strong&gt; 模型的开山鼻祖. 接下来我们就要插入介绍.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2FQQ_1762436065427.DskMZwvK.png&amp;amp;w=1172&amp;amp;h=520&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;3. Bradley-Terry模型&lt;/h2&gt;
&lt;p&gt;在生活中, 经常需要对一组对象进行比较和排序, 但是一些比较中无法给出绝对的分数, 不同人的评价标准和尺度不一致, 直接打分有主观上的偏差.  早在1952年, Rank analysis of incomplete block designs: I. The method of paired comparisons中就提出了这类问题的解决方法, 其核心思想就是现在RM中构建目标函数所用到的Bradley-Terry模型.&lt;/p&gt;
&lt;p&gt;它假设, 每个对象i都有一个潜在的“能力”用参数$\pi_i$ ($\pi_i$ &amp;gt;0) 表示, 我们可以将其理解为对象i的一种能力或置信度. 当i与j进行对比的时候, i被选择的概率定义为:
$$
P \left( i \succ j \right)= \frac{ \pi_{i}}{ \pi_{i}+ \pi_{j}} \tag{2.3.1}
$$
观察可以得知, $\pi_i =\pi_j$ 的时候, 两个对象呗选中的概率相等, 而如果前者远大于后者, 则i基本总是胜出. 这样, 我们只需要根据若干次i于j之间的排序结果, 利用&lt;strong&gt;最大似然估计&lt;/strong&gt;就可以估计到$P \left( i \succ j \right)$ 的概率.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;概率论可能有的有点忘了, 当我们只知道采样结果但是不知道概率函数的参数时, 我们可以直接将采样的结果代入函数相乘, 调整未知参数 (求导) 让乘积最大, 这样就可以估计原本不知道的参数. 这个方法的原理直观来看, 是正确的参数 (正确的原本概率函数) 一定会使采样发生的概率最大, 乘积最大. 作为无偏估计, 最大似然估计的方差很小, 当采样数量非常多的时候就接近真实值.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而为了方便计算和归一化, 通常将$\pi_i$ 写成指数形式, $\pi_{i}=e^{ r_{j}}$ (奖励有可能是负数), 所以, 原式子可以写作:
$$
P \left( j \succ k \right)= \frac{e^{ r_{j}}}{e^{ r_{j}}+e^{ r_{k}}}=\frac{1}{1+e^{-({r_{j}-r_k})}}=\sigma(r_j-r_k) \tag{2.3.2}
$$
我们写成对数似然函数的形式:
$$
\ln L= \sum_{jk}n_{jk} \ln P \left( j&amp;gt;k \right) \tag{2.3.3}
$$
我们需要让P尽可能大, 可以让每个对数似然函数都大, 但是机器学习通常是最小化损失函数, 所以我们将其写成负对数似然的形式, 得到我们需要的损失函数:
$$
loss=-log(L)=-log(\sigma(r_j-r_k)) \tag{2.3.4}
$$&lt;/p&gt;
&lt;p&gt;也就得到了上图中的loss了.&lt;/p&gt;
&lt;p&gt;一旦训练完成之后, 就可以将奖励模型作为二元分类器, 为正类和负类提供一组logits (深度学习模型预测过程中的最后一层输出的原始值, 激活之前). 再使用softmax函数得到概率值.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251106212348.D5-tfh_4.png&amp;amp;w=1570&amp;amp;h=504&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. 利用强化学习进行微调&lt;/h2&gt;
&lt;p&gt;通过1-3, 我们已经得到了可以取代人类的RM, 接下来我们就要利用这个RM+强化学习来对LLM进行微调. 过程如下图所示, 我们不断迭代更新Instruct LLM, 将得到的中间过程称为RL-updated LLM. 如果更新顺利, 我们可以看到每次迭代后奖励分数都在提高. 继续这个过程, 直到你的模型的对齐结果达到一定的评估标准 (步长或者阈值) , 最终我们会得到我们需要的Human-aligned LLM .&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251106214828.DIvbceAk.png&amp;amp;w=1388&amp;amp;h=702&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
其中RL algorithm可以选择很多种不同的算法. 而正如前面所说, PPO是其中的热门算法, 需要说明的是, 虽然前面已经介绍过了PPO算法, 但是这里依然要讨论PPO在大语言模型的特定背景下是如何工作的.&lt;/p&gt;
&lt;h2&gt;5. PPO算法的应用&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;笔记(四)中已经介绍了PPO算法, 一言以蔽之就是用重要性采样的方式使用旧策略数据更新目前策略, 并用KL散度或者clip函数限制新旧策略差距, 从而达到有效率又稳定的更新.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;(1) Policy loss&lt;/h3&gt;
&lt;p&gt;我们首先把单个时间步t的PPO的策略挪过来:
$$
L^{POLICY}=\min \left(\frac{\pi_{\theta}\left(a_{t}|s_{t}\right)}{\pi_{\theta_{old}}\left(a_{t}|s_{t}\right)}\hat{A_t},\right. \left.\text{clip}\left(\frac{\pi_{\theta}\left(a_{t}|s_{t}\right)}{\pi_{\theta_{old}}\left(a_{t}|s_{t}\right)},1-\varepsilon,1+\varepsilon\right)\hat{A_t}\right) \tag{2.5.1}
$$
我们现在要做的就是理解在具体RLHF的情境中, 以上的量分别表示什么. 我们将LLM生成文本的过程看成马尔可夫决策过程, 动作a是模型选择生成的下一个词元 (token), 状态s则是在生成这个词元之前看到的所有上文 (包含prompt和已经生成的词元). 我们的目标是让生成词元的策略变成最优.&lt;/p&gt;
&lt;p&gt;$\pi_\theta$ 是正在被训练的新策略 (当前版本的LLM) 在给定$s_t$ 后, 选择下一个词元$a_t$ 的概率. $\pi_{\theta_{old}}$ 是旧策略 (收集这批数据时版本的LLM) 在相同上下文$s_t$ 后, 选择同一个词元$a_t$ 的概率.&lt;/p&gt;
&lt;p&gt;$\hat{A_t}$ 就是优势函数的估计, 它衡量的在状态$s_t$ 下选择$a_t$ , 相比在该状态下选择“平均”动作要好多少, 这个前面已经说过, 它基于的是baseline的方法, 将价值转化为优势. 而这个信号, 最终来源于奖励模型, 它会对整个生成的序列 (一个completion) 给出一个分数, &lt;strong&gt;然后通过GAE等技术分配给每一个词元&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251108232556.Cvoi25cw.png&amp;amp;w=1426&amp;amp;h=604&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251108232536.CTtEEE48.png&amp;amp;w=1412&amp;amp;h=554&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(2) Value loss&lt;/h3&gt;
&lt;p&gt;首先我们需要强调一下, PPO算法属于Actor-Critic框架, 所以有两个可以训练的框架.&lt;/p&gt;
&lt;p&gt;其中之一是Actor网络 (策略网络) $\pi_\theta(a|s)$ , 这就是要微调的语言模型本身, 负责决定给定上下文生成什么词, 这个网络是通过$L^{POLICY}$ 和$L^{ENT}$ 来更新.&lt;/p&gt;
&lt;p&gt;而另一个就是Critic网络 (价值网络) $V_\phi(s)$ , 这是一个独立的神经网络, 负责预测状态价值, 而不参与文本生成.&lt;/p&gt;
&lt;p&gt;在训练中, 对于每个生成序列, 奖励模型RM给出最终奖励R, 然后对于序列中的每个位置, 都会计算折扣回报$R_t$ , 我们最终让Critic网络的预测更加接近这个实际回报.
$$
{L^{VF}}=\frac{1}{2}\left|V_{\theta}(s)-\left(\sum_{t=0}^{T}\gamma^{t} r_{t}\mid s_{0}=s\right)\right|_{2}^{2} \tag{2.5.2}
$$
我们已经有了RM来给出整个序列的奖励, 但是PPO算法中, 是需要每个时间步 (每生成每一个token后) 的奖励.&lt;/p&gt;
&lt;p&gt;我们来举个例子, 假设真实情况下, A dog is a奖励为0.34, A dog is a furry奖励是1.23, A dog is a furry animal奖励是1.87, 所以总奖励真实值为1.87. 我们来看看细致的过程:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;初始化&lt;/li&gt;
&lt;li&gt;数据(序列)收集&lt;/li&gt;
&lt;li&gt;获得奖励&lt;/li&gt;
&lt;li&gt;计算回报&lt;/li&gt;
&lt;li&gt;Critic网络预测&lt;/li&gt;
&lt;li&gt;计算Value Loss更新Critic&lt;/li&gt;
&lt;li&gt;计算优势函数&lt;/li&gt;
&lt;li&gt;计算策略损失并更新Actor&lt;/li&gt;
&lt;li&gt;熵正则化&lt;/li&gt;
&lt;li&gt;整体效果&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2FQQ_1762669027968.DT-g3rcX.png&amp;amp;w=2026&amp;amp;h=938&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(3) Entropy loss&lt;/h3&gt;
&lt;p&gt;$$
L^{\mathit{ENT}}=\mathrm{entropy}\left(\pi_{\theta}\left(\cdot\mid s_{t} \right)\right) \tag{2.5.3}
$$
这里还有一个组件, &lt;strong&gt;熵损失 (Entropy Loss)&lt;/strong&gt;. 当策略损失模型将模型向对齐目标移动时, 熵允许模型保持创造性. 如果低熵状态下, 可能总会按照同样的方式来生成词语. 这有点类似于LLM的温度设置, 不同的是温度在推理时影响模型的创新性, 而熵则是在训练期间就影响模型的创新性.&lt;/p&gt;
&lt;h3&gt;(4) PPO目标&lt;/h3&gt;
&lt;p&gt;接下来, 我们对PPO在RLHF中的公式进行总结, 它是一个由三个部分组成的公式, 为了理解, 我们现在再次总结其中各个量在RLHF中的应用.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;策略损失: 它来自于PPO算法本身, 是策略优化的核心, 可以看成AC框架的Actor部分, 用于update原本的LLM, 从而使模型倾向于生成能获得高奖励的文本.&lt;/li&gt;
&lt;li&gt;价值损失: 它来自于Critic网络的训练目标.&lt;/li&gt;
&lt;li&gt;熵正则项&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251109001117.Dl29YVAh.png&amp;amp;w=1088&amp;amp;h=286&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;5. 奖励投机行为 (reward hacking)&lt;/h2&gt;
&lt;p&gt;有时候, 代理通过选择使其获得最大奖励的行为来欺骗系统, 即使这些行动并不符合原始的目标. 奖励投机行为可以表现为在输出中加入能得到高分数的单词或短语, 但是却降低了语言的整体质量.&lt;/p&gt;
&lt;p&gt;下面的例子中, 为了降低Toxicity, LLM更新权重后加入了很多夸张的短语, LLM也可能通过无意义的、语法不正确的文本, 只是恰好以类似的方式最大化奖励.
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251109123501.DuRKn8pU.png&amp;amp;w=974&amp;amp;h=428&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
为了避免这种行为, 可以用最初的指导的LLM作为性能参考, 冻结其参数, 称为参考模型 (reference model). 然后, 我们在每个prompt生成的completion中将两者每一个生成的词元都比较计算KL散度(这是一个相对计算密集的过程), 然后将这个和Reward一并交给PPO算法.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251109124957.CUImX8fy.png&amp;amp;w=1106&amp;amp;h=480&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
我们在笔记(五)中说明了PPO有两个重要变种即PPO-Clip和PPO-Penalty. 这里把Reference Model和RL-updated LLM进行KL散度的计算, 然后也交给PPO算法, 实际上可以看成是两者的结合. 这种混合方法更加鲁棒.&lt;/p&gt;
&lt;p&gt;这里也可以和PEFT进行结合, 这样只要更新适配器的权重, 而不用更新全部LLM的权重.&lt;/p&gt;
&lt;h2&gt;6. Constitutional AI&lt;/h2&gt;
&lt;p&gt;人力是有限的资源, 通常需要数以千计的人来进行标注, 所以扩大人类反馈也是一个研究的方向. 其中一种方法就是Constitutional AI. 这是一套根据管理模式行为的规则和原则来训练模型的策略. 通过和样本提示词结合, 构建了模型的Constitution. 接着, 会教模型自我评估并根据这些准则调整生成结果.&lt;/p&gt;
&lt;p&gt;Constitutional AI不仅可以扩大人类反馈的规模, 也可以帮助模型在RLHF中表现更好 -- 向模型提供一组Constitution有助于模型在冲突的利益中找到平衡, 避免意外情况. 比如在某些情景优先考虑有用性而忘记了有害性的控制.&lt;/p&gt;
&lt;p&gt;下面是2022年的“Constitutional AI: Harmlessness from AI Feedback”中constitutional principles例子:
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251109141052.CmhlJEpd.png&amp;amp;w=1364&amp;amp;h=454&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在这个流程中, 我们又将SFT给拿了回来. 模型开始会根据宪法原则, 自我批判其初始回答并进行修改, 并通过这个过程, 让模型学会如何依据原则来改进输出.&lt;/p&gt;
&lt;p&gt;然后, 我们再进入强化学习的阶段, 一个AI模型 (标注者) 会根据宪法原则, 来对不同回答的偏好进行判断, 基于这些判断来训练一个奖励模型RM, 这就是&lt;strong&gt;RLAIF (Reinforce Learning from AI Feedback)&lt;/strong&gt; 思想的体现. 再之后就和之前的过程一样, 用PPO算法优化策略.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[开始] --&amp;gt; B[“监督学习阶段&amp;lt;br&amp;gt;自我批判与修改”]
    B --&amp;gt; C[“训练奖励模型&amp;lt;br&amp;gt;RLAIF范式”]
    C --&amp;gt; D[“强化学习阶段&amp;lt;br&amp;gt;使用PPO算法优化”]
    D --&amp;gt; E[产出对齐模型]
    
    subgraph 宪法原则
        F[预先定义的原则库]
    end
    
    F --&amp;gt; B
    F --&amp;gt; C
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以, 这里的关键就是怎么用AI来替代人类排序和微调. 我们的方法有以下三个方面:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;使用使用高质量的、由人类编写的指令数据对一个大语言模型进行 SFT. 这些数据通常包含了符合人类价值观的复杂推理和判断&lt;/li&gt;
&lt;li&gt;让该模型深入学习并理解那套成文的“宪法”原则. 这些原则是具体、可操作的指令, 例如：选择那个更无害、更不会冒犯他人的回答”、 “选择那个更诚实、避免胡编造的回答”、“选择那个更有帮助、更清晰地解决问题的回答”.&lt;/li&gt;
&lt;li&gt;训练或引导该模型在做出判断时, &lt;strong&gt;必须输出其推理过程&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样一来, 我们就得到了一个可靠的AI标注者. 我们可以通过这个标注者批量生成偏好数据, 然后用这个偏好数据来训练RM.&lt;/p&gt;
&lt;p&gt;现在, 我们终于可以完全看懂2022年Training a Helpful and Harmless Assistant with RLHF论文的图了
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251018224720.huUC37E6.png&amp;amp;w=1866&amp;amp;h=1022&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>FastAPI 扩展层：中间件、CORS 与后台任务</title><link>https://owen571.top/posts/study/fastapi/09-fastapi-%E4%B8%AD%E9%97%B4%E4%BB%B6-cors-%E4%B8%8E%E5%90%8E%E5%8F%B0%E4%BB%BB%E5%8A%A1/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/09-fastapi-%E4%B8%AD%E9%97%B4%E4%BB%B6-cors-%E4%B8%8E%E5%90%8E%E5%8F%B0%E4%BB%BB%E5%8A%A1/</guid><description>把路由之外那层请求包裹逻辑收起来：中间件、CORS 配置，以及请求结束后再执行的后台任务。</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;写到这里之后，FastAPI 里的“路由函数”已经不是唯一重点了。请求在进入路由前、离开路由后，还会经过另外一层东西。&lt;/p&gt;
&lt;h2&gt;1. 中间件的基本形状&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import time
from fastapi import FastAPI, Request

app = FastAPI()


@app.middleware(&quot;http&quot;)
async def add_process_time_header(request: Request, call_next):
    start_time = time.perf_counter()
    response = await call_next(request)
    process_time = time.perf_counter() - start_time
    response.headers[&quot;X-Process-Time&quot;] = str(process_time)
    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中间件可以直接理解成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;请求先经过它&lt;/li&gt;
&lt;li&gt;它再把请求交给路由&lt;/li&gt;
&lt;li&gt;路由返回响应后，它还能继续处理响应&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以它特别适合做：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求耗时统计&lt;/li&gt;
&lt;li&gt;日志&lt;/li&gt;
&lt;li&gt;统一响应头&lt;/li&gt;
&lt;li&gt;跨域&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;官方中间件页还特地提到，测耗时更适合用 &lt;code&gt;time.perf_counter()&lt;/code&gt; 而不是 &lt;code&gt;time.time()&lt;/code&gt;。&lt;br /&gt;
来源：Middleware 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/middleware/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/middleware/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;2. 多个中间件的顺序&lt;/h2&gt;
&lt;p&gt;如果有多个中间件，最后添加的会在最外层。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.add_middleware(MiddlewareA)
app.add_middleware(MiddlewareB)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求流会是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求：&lt;code&gt;MiddlewareB -&amp;gt; MiddlewareA -&amp;gt; 路由&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;响应：&lt;code&gt;路由 -&amp;gt; MiddlewareA -&amp;gt; MiddlewareB&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一点一开始不容易直觉化，但把它想成洋葱模型就清楚了：后加的包在外面。&lt;/p&gt;
&lt;h2&gt;3. CORS 不是 FastAPI 特性，而是浏览器跨域规则&lt;/h2&gt;
&lt;p&gt;很多人第一次碰 CORS 时，会觉得这是框架自带的怪东西。其实本质上它是浏览器的跨域限制，FastAPI 只是提供了一个标准中间件来处理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins = [
    &quot;http://localhost.tiangolo.com&quot;,
    &quot;https://localhost.tiangolo.com&quot;,
    &quot;http://localhost&quot;,
    &quot;http://localhost:8080&quot;,
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=[&quot;*&quot;],
    allow_headers=[&quot;*&quot;],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档里有一个特别值得记的细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果 &lt;code&gt;allow_credentials=True&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;那么 &lt;code&gt;allow_origins&lt;/code&gt;、&lt;code&gt;allow_methods&lt;/code&gt;、&lt;code&gt;allow_headers&lt;/code&gt; 不能都写成 &lt;code&gt;[&quot;*&quot;]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它们必须显式指定。&lt;br /&gt;
来源：CORS 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/cors/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/cors/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;4. CORS 参数真正值得理解的几个&lt;/h2&gt;
&lt;p&gt;最常用的就是这些：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;allow_origins&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allow_origin_regex&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allow_methods&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allow_headers&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allow_credentials&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expose_headers&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_age&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第一次配置时最容易出问题的通常是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前端地址没写对&lt;/li&gt;
&lt;li&gt;明明带 cookie / token，却还在用全通配符&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. &lt;code&gt;BackgroundTasks&lt;/code&gt; 的位置&lt;/h2&gt;
&lt;p&gt;后台任务不是任务队列系统，它更像“响应返回后，顺手把一个小尾巴继续做完”。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import BackgroundTasks, FastAPI

app = FastAPI()


def write_notification(email: str, message: str = &quot;&quot;):
    with open(&quot;log.txt&quot;, mode=&quot;a&quot;) as email_file:
        email_file.write(f&quot;notification for {email}: {message}\\n&quot;)


@app.post(&quot;/send-notification/{email}&quot;)
async def send_notification(email: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(write_notification, email, message=&quot;some notification&quot;)
    return {&quot;message&quot;: &quot;Notification sent in the background&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的意思是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先把响应返回给客户端&lt;/li&gt;
&lt;li&gt;再在后台补做一些轻量工作&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. 后台任务也能进依赖系统&lt;/h2&gt;
&lt;p&gt;官方文档特别提到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BackgroundTasks&lt;/code&gt; 也可以参与依赖注入&lt;/li&gt;
&lt;li&gt;在路径函数、依赖、子依赖里声明都可以&lt;/li&gt;
&lt;li&gt;FastAPI 会把它们合并到同一个对象上&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import BackgroundTasks, Depends, FastAPI

app = FastAPI()


def write_log(message: str):
    with open(&quot;log.txt&quot;, mode=&quot;a&quot;) as log:
        log.write(message)


def get_query(background_tasks: BackgroundTasks, q: str | None = None):
    if q:
        background_tasks.add_task(write_log, f&quot;found query: {q}\\n&quot;)
    return q


@app.post(&quot;/send-notification/{email}&quot;)
async def send_notification(
    email: str,
    background_tasks: BackgroundTasks,
    q: Annotated[str | None, Depends(get_query)],
):
    background_tasks.add_task(write_log, f&quot;message to {email}\\n&quot;)
    return {&quot;message&quot;: &quot;Message sent&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;来源：Background Tasks 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/background-tasks/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/background-tasks/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;7. 这一层的边界&lt;/h2&gt;
&lt;p&gt;中间件、CORS、后台任务这三件事放在一起很合理，因为它们都属于“不是业务字段本身，但又会围绕请求工作”的层。&lt;/p&gt;
&lt;p&gt;它们不像路径参数或请求体那样直接决定接口数据结构，却会很明显地影响：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求经过的路径&lt;/li&gt;
&lt;li&gt;浏览器能不能调通&lt;/li&gt;
&lt;li&gt;响应返回后的尾部动作&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>LangChain 核心组件 02：Messages</title><link>https://owen571.top/posts/study/langchain/04-messages/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/04-messages/</guid><description>理清 LangChain 里最核心的数据单位：不同消息类型、内容块、多模态输入，以及它们为什么是模型上下文的基础。</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;官方文档里 Messages 放在 Models 后面、但 Agents 前面，而且很多地方又提前引用它。我这里把它明确放在第二个组件位置，因为模型真正吃进去的上下文，本质上就是消息。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;消息是 LangChain 中模型上下文的基本单元，它们代表模型的输入与输出，在与大语言模型交互时，承载着表征对话状态所需的内容和元数据。&lt;/p&gt;
&lt;p&gt;消息是包含以下内容的对象：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;角色 - 标识消息类型（例如 系统、用户）&lt;/li&gt;
&lt;li&gt;内容 - 表示消息的实际内容（如文本、图像、音频、文档等）&lt;/li&gt;
&lt;li&gt;元数据 - 可选字段，例如响应信息、消息 ID 和令牌使用量&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LangChain 提供了适用于所有模型提供商的标准消息类型，确保无论调用何种模型，行为都保持一致。&lt;/p&gt;
&lt;h2&gt;2. 基础使用&lt;/h2&gt;
&lt;p&gt;最简单的应用方法就是invoke模型（或者agent）的时候传入，下面给了用messages包里的传入方法，也可以直接像之前一样写成一个字典：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage, AIMessage, SystemMessage

model = init_chat_model(&quot;gpt-5-nano&quot;)

system_msg = SystemMessage(&quot;You are a helpful assistant.&quot;)
human_msg = HumanMessage(&quot;Hello, how are you?&quot;)

# Use with chat models
messages = [system_msg, human_msg]
response = model.invoke(messages)  # Returns AIMessage
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(1) 文本提示词&lt;/h3&gt;
&lt;p&gt;文本提示词是字符串 —— 非常适合无需保留对话历史的简单生成任务。调用也很简单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;response = model.invoke(&quot;Write a haiku about spring&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 消息提示词&lt;/h3&gt;
&lt;p&gt;或者，你也可以通过提供消息对象列表的方式，向模型传入一组消息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import SystemMessage, HumanMessage, AIMessage

messages = [
    SystemMessage(&quot;You are a poetry expert&quot;),
    HumanMessage(&quot;Write a haiku about spring&quot;),
    AIMessage(&quot;Cherry blossoms bloom...&quot;)
]
response = model.invoke(messages)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有这样，才能启动多轮对话、加入多模态内容、加入系统提示等。&lt;/p&gt;
&lt;h3&gt;(3) 字典形式&lt;/h3&gt;
&lt;p&gt;只是(2)的一种变体，一般写这个更方便：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;messages = [
    {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;You are a poetry expert&quot;},
    {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Write a haiku about spring&quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;Cherry blossoms bloom...&quot;}
]
response = model.invoke(messages)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 消息类型 (Message types)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;System message - 告知模型行为方式并为交互提供上下文&lt;/li&gt;
&lt;li&gt;Human message  - 代表用户输入以及与模型的交互&lt;/li&gt;
&lt;li&gt;AI message  - 由模型生成的响应，包含文本内容、工具调用和元数据&lt;/li&gt;
&lt;li&gt;Tool message - 代表工具调用的输出结果&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) System message&lt;/h3&gt;
&lt;p&gt;SystemMessage是一组初始指令，用于设定模型的行为模式。你可以通过系统消息来设定沟通基调、定义模型角色，并制定回复准则，如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import SystemMessage, HumanMessage

system_msg = SystemMessage(&quot;&quot;&quot;
You are a senior Python developer with expertise in web frameworks.
Always provide code examples and explain your reasoning.
Be concise but thorough in your explanations.
&quot;&quot;&quot;)

messages = [
    system_msg,
    HumanMessage(&quot;How do I create a REST API?&quot;)
]
response = model.invoke(messages)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) Human message&lt;/h3&gt;
&lt;p&gt;HumanMessage代表用户的输入与交互行为。它们可以包含文本、图像、音频、文件以及任意数量的多模态内容。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;response = model.invoke([
  HumanMessage(&quot;What is machine learning?&quot;)
])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以给消息添加一些元数据。这个部分需要查看运营商具体支持的字段，如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;human_msg = HumanMessage(
    content=&quot;Hello!&quot;,
    name=&quot;alice&quot;,  # Optional: identify different users
    id=&quot;msg_123&quot;,  # Optional: unique identifier for tracing
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) AI message&lt;/h3&gt;
&lt;p&gt;AIMessage代表模型调用的输出结果。它们可以包含多模态数据、工具调用以及可供后续访问的服务提供商专属元数据。我们上一章已经拆解过AIMessage里面都有什么了，这里不再细说。&lt;/p&gt;
&lt;p&gt;需要注意一点就是不同服务提供方对消息类型的权重分配与语境处理方式各不相同，这意味着有时手动创建一个新的AIMessage对象，并将其插入消息历史中，使其看起来像是由模型生成的，会很有帮助，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import AIMessage, SystemMessage, HumanMessage

# Create an AI message manually (e.g., for conversation history)
ai_msg = AIMessage(&quot;I&apos;d be happy to help you with that question!&quot;)

# Add to conversation history
messages = [
    SystemMessage(&quot;You are a helpful assistant&quot;),
    HumanMessage(&quot;Can you help me?&quot;),
    ai_msg,  # Insert as if it came from the model
    HumanMessage(&quot;Great! What&apos;s 2+2?&quot;)
]

response = model.invoke(messages)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(4) Tool message&lt;/h3&gt;
&lt;p&gt;对于支持工具调用的模型，AI 消息可以包含工具调用。工具消息用于将单次工具执行的结果回传给模型。&lt;/p&gt;
&lt;p&gt;不过工具可以直接生成ToolMessage对象。下面我们展示一个简单示例，具体在Tool那一章细说：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import AIMessage
from langchain.messages import ToolMessage

# After a model makes a tool call
# (Here, we demonstrate manually creating the messages for brevity)
ai_message = AIMessage(
    content=[],
    tool_calls=[{
        &quot;name&quot;: &quot;get_weather&quot;,
        &quot;args&quot;: {&quot;location&quot;: &quot;San Francisco&quot;},
        &quot;id&quot;: &quot;call_123&quot;
    }]
)

# Execute tool and create result message
weather_result = &quot;Sunny, 72°F&quot;
tool_message = ToolMessage(
    content=weather_result,
    tool_call_id=&quot;call_123&quot;  # Must match the call ID
)

# Continue conversation
messages = [
    HumanMessage(&quot;What&apos;s the weather in San Francisco?&quot;),
    ai_message,  # Model&apos;s tool call
    tool_message,  # Tool execution result
]
response = model.invoke(messages)  # Model processes the result
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 消息内容 (Message content)&lt;/h2&gt;
&lt;p&gt;你可以将消息的内容视作发送给模型的数据载荷。消息具备content属性，该属性为松散类型，支持字符串以及无类型对象列表（如字典）。这使得 LangChain 聊天模型能够直接兼容服务商原生结构，例如多模态内容及其他数据。&lt;/p&gt;
&lt;p&gt;此外，LangChain 还为文本、推理、引用、多模态数据、服务端工具调用及其他消息内容提供了专用的内容类型。详见下方的content block的说明。&lt;/p&gt;
&lt;p&gt;LangChain 聊天模型通过content属性接收消息内容。
该属性可包含以下任一形式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;字符串&lt;/li&gt;
&lt;li&gt;服务商原生格式的内容块列表&lt;/li&gt;
&lt;li&gt;LangChain 标准内容块的列表&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里提供一个多模态的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import HumanMessage

# String content
human_message = HumanMessage(&quot;Hello, how are you?&quot;)

# Provider-native format (e.g., OpenAI)
human_message = HumanMessage(content=[
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;Hello, how are you?&quot;},
    {&quot;type&quot;: &quot;image_url&quot;, &quot;image_url&quot;: {&quot;url&quot;: &quot;https://example.com/image.jpg&quot;}}
])

# List of standard content blocks
human_message = HumanMessage(content_blocks=[
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;Hello, how are you?&quot;},
    {&quot;type&quot;: &quot;image&quot;, &quot;url&quot;: &quot;https://example.com/image.jpg&quot;},
])
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(1) 标准内容块 (Standard content blocks)&lt;/h3&gt;
&lt;p&gt;消息对象实现了 content_blocks 属性，该属性会惰性解析 content 属性，将其转换为标准的类型安全表示形式。例如，由 ChatAnthropic 或 ChatOpenAI 生成的消息会包含对应服务商格式的 thinking 或 reasoning 内容块，但可被惰性解析为统一的 ReasoningContentBlock表示形式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import AIMessage

message = AIMessage(
    content=[
        {
            &quot;type&quot;: &quot;reasoning&quot;,
            &quot;id&quot;: &quot;rs_abc123&quot;,
            &quot;summary&quot;: [
                {&quot;type&quot;: &quot;summary_text&quot;, &quot;text&quot;: &quot;summary 1&quot;},
                {&quot;type&quot;: &quot;summary_text&quot;, &quot;text&quot;: &quot;summary 2&quot;},
            ],
        },
        {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;...&quot;, &quot;id&quot;: &quot;msg_abc123&quot;},
    ],
    response_metadata={&quot;model_provider&quot;: &quot;openai&quot;}
)
print(message.content_blocks)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;打印结果如下，证明被成功解析。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[{&apos;type&apos;: &apos;reasoning&apos;, &apos;id&apos;: &apos;rs_abc123&apos;, &apos;reasoning&apos;: &apos;summary 1&apos;},
 {&apos;type&apos;: &apos;reasoning&apos;, &apos;id&apos;: &apos;rs_abc123&apos;, &apos;reasoning&apos;: &apos;summary 2&apos;},
 {&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos;...&apos;, &apos;id&apos;: &apos;msg_abc123&apos;}]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 多模态&lt;/h3&gt;
&lt;p&gt;多模态指的是处理文本、音频、图像和视频等不同形式数据的能力。LangChain 包含可在不同服务提供商之间通用的此类数据标准类型。&lt;/p&gt;
&lt;p&gt;聊天模型能够接收多模态数据作为输入，并将其生成为输出。我们只需要简单的将content的类型设置成需要的类型，比如text、image、file（pdf）、audio、video等。前面不少地方都举过例子。&lt;/p&gt;
&lt;h3&gt;(3) Content block reference&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;content_blocks&lt;/code&gt; 是 LangChain v1 引入的标准化消息内容表示方式。&lt;br /&gt;
它不是对 &lt;code&gt;content&lt;/code&gt; 的替代，而是把不同 Provider 的消息内容统一整理成一组带类型的字典，便于跨模型访问和处理。&lt;/p&gt;
&lt;p&gt;每个 block 都会带一个 &lt;code&gt;type&lt;/code&gt; 字段，常见类型可以分为下面几类。&lt;/p&gt;
&lt;h4&gt;1. Core&lt;/h4&gt;
&lt;p&gt;最基础的内容类型。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;text&lt;/code&gt;&lt;br /&gt;
标准文本内容。&lt;br /&gt;
常见字段包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;text&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;annotations&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;extras&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;reasoning&lt;/code&gt;&lt;br /&gt;
模型的推理内容。&lt;br /&gt;
常见字段包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;reasoning&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reasoning&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;extras&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2. Multimodal&lt;/h4&gt;
&lt;p&gt;用于多模态输入或输出。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;image&lt;/code&gt;&lt;br /&gt;
图片内容。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;image&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt; / &lt;code&gt;base64&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mime_type&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;audio&lt;/code&gt;&lt;br /&gt;
音频内容。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;audio&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt; / &lt;code&gt;base64&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mime_type&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;video&lt;/code&gt;&lt;br /&gt;
视频内容。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;video&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt; / &lt;code&gt;base64&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mime_type&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;file&lt;/code&gt;&lt;br /&gt;
通用文件内容，例如 PDF。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;file&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt; / &lt;code&gt;base64&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mime_type&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;text-plain&lt;/code&gt;&lt;br /&gt;
纯文本文档内容，例如 &lt;code&gt;.txt&lt;/code&gt;、&lt;code&gt;.md&lt;/code&gt;。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;text-plain&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mime_type&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. Tool Calling&lt;/h4&gt;
&lt;p&gt;和工具调用有关的内容块。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;tool_call&lt;/code&gt;&lt;br /&gt;
普通工具调用。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;tool_call&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;args&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;tool_call_chunk&lt;/code&gt;&lt;br /&gt;
流式输出中的工具调用片段。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;tool_call_chunk&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;args&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;index&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;invalid_tool_call&lt;/code&gt;&lt;br /&gt;
无法正确解析的工具调用，一般用于捕获 JSON 解析失败等问题。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;invalid_tool_call&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;args&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;error&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;4. Server-Side Tool Execution&lt;/h4&gt;
&lt;p&gt;和服务端工具执行有关的内容块。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;server_tool_call&lt;/code&gt;&lt;br /&gt;
服务端执行的工具调用。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;server_tool_call&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;args&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;server_tool_call_chunk&lt;/code&gt;&lt;br /&gt;
服务端工具调用的流式片段。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;server_tool_call_chunk&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;args&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;index&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;server_tool_result&lt;/code&gt;&lt;br /&gt;
服务端工具执行结果。常见字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;server_tool_result&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tool_call_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;output&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;5. Provider-Specific&lt;/h4&gt;
&lt;p&gt;用于放置服务商特有、暂时无法标准化的内容。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;non_standard&lt;/code&gt;&lt;br /&gt;
Provider 专有的逃生口。常见字段：
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type=&quot;non_standard&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;value&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;总的来说，&lt;code&gt;content_blocks&lt;/code&gt; 的意义就在于：即使不同模型底层返回的原始格式不一样，LangChain 也尽量帮我们统一成一套更稳定的访问方式。&lt;/p&gt;
&lt;h3&gt;(4) Use with chat models&lt;/h3&gt;
&lt;p&gt;Chat model 接收一组 messages 作为输入，并通常返回一个 &lt;code&gt;AIMessage&lt;/code&gt; 作为输出。&lt;br /&gt;
如果消息中包含标准化的 &lt;code&gt;content_blocks&lt;/code&gt;，那么我们就可以更稳定地处理文本、推理、多模态数据以及工具调用结果，而不用总是去适配不同 Provider 的原始格式。&lt;/p&gt;
&lt;p&gt;不过需要注意的是，&lt;code&gt;content_blocks&lt;/code&gt; 主要是 LangChain 提供的一层标准化抽象，它并不意味着所有模型都支持所有类型的内容。像图片、音频、PDF、视频等输入形式，仍然要以具体 Provider 的能力说明为准。&lt;/p&gt;
</content:encoded></item><item><title>PyTorch 手写 Transformer：从模块拆解到 toy task</title><link>https://owen571.top/posts/study/pytorch/07-pytorch-%E6%89%8B%E5%86%99-transformer-%E5%AE%9E%E7%8E%B0%E6%8B%86%E8%A7%A3/</link><guid isPermaLink="true">https://owen571.top/posts/study/pytorch/07-pytorch-%E6%89%8B%E5%86%99-transformer-%E5%AE%9E%E7%8E%B0%E6%8B%86%E8%A7%A3/</guid><description>不直接调用 nn.Transformer，而是手写位置编码、多头注意力、Encoder / Decoder，并用一个反转序列的 toy task 跑通训练与解码。</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇主要整理自 &lt;code&gt;pytorch_using/transformer.py&lt;/code&gt;。这份代码的价值不在于“重新发明一个工业级 Transformer”，而在于把 Transformer 拆成可验证、可训练、可调试的模块。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 为什么要手写一版 Transformer&lt;/h2&gt;
&lt;p&gt;直接用 &lt;code&gt;nn.Transformer&lt;/code&gt; 当然更快，但我自己一直觉得，想真的理解 Transformer，至少要完整看一遍这些模块是怎么拼起来的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;位置编码&lt;/li&gt;
&lt;li&gt;padding mask&lt;/li&gt;
&lt;li&gt;causal mask&lt;/li&gt;
&lt;li&gt;scaled dot-product attention&lt;/li&gt;
&lt;li&gt;multi-head attention&lt;/li&gt;
&lt;li&gt;feed-forward&lt;/li&gt;
&lt;li&gt;encoder / decoder layer&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把这些都走通一遍之后，再回去看高级封装，心里会稳很多。&lt;/p&gt;
&lt;h2&gt;2. 这份实现统一采用 &lt;code&gt;batch_first&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;代码一开头就明确了形状约定：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;token id: [B, S]
embedding 后: [B, S, D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个约定非常好，因为后面所有 shape 变化都能围绕它来理解。&lt;/p&gt;
&lt;h2&gt;3. 位置编码：让模型知道“顺序”&lt;/h2&gt;
&lt;p&gt;这份实现里的 &lt;code&gt;PositionalEncoding&lt;/code&gt; 很标准：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000):
        super().__init__()
        pe = torch.zeros(max_len, d_model, dtype=torch.float32)
        position = torch.arange(max_len, dtype=torch.float32).unsqueeze(1)
        div_term = torch.exp(
            torch.arange(0, d_model, 2, dtype=torch.float32)
            * (-math.log(10000.0) / d_model)
        )
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)
        self.register_buffer(&quot;pe&quot;, pe)

    def forward(self, x):
        s = x.size(1)
        return x + self.pe[:, :s, :]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段最关键的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;输入输出都保持 &lt;code&gt;[B, S, D]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;位置向量不是参数，而是 buffer&lt;/li&gt;
&lt;li&gt;它解决的是“Attention 本身不带顺序感”的问题&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. Mask：谁该被遮住&lt;/h2&gt;
&lt;p&gt;这份代码把两种最重要的 mask 都单独实现了：&lt;/p&gt;
&lt;h3&gt;4.1 Padding Mask&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def make_padding_mask(seq, pad_id=0):
    mask = (seq == pad_id)
    return mask.unsqueeze(1).unsqueeze(2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它解决的是：&lt;br /&gt;
补齐出来的 &lt;code&gt;PAD&lt;/code&gt; 不应该参与有效注意力。&lt;/p&gt;
&lt;h3&gt;4.2 Causal Mask&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def make_causal_mask(seq_len, device=None):
    return (
        torch.triu(torch.ones(seq_len, seq_len, device=device), diagonal=1)
        .unsqueeze(0)
        .unsqueeze(0)
        == 1
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它解决的是：&lt;br /&gt;
Decoder 在生成当前 token 时，不能偷看未来位置。&lt;/p&gt;
&lt;h2&gt;5. Attention 的核心主线&lt;/h2&gt;
&lt;p&gt;这一段我特别喜欢原代码里写的复习清单，因为它几乎就是最短背诵版：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;QK^T
/ sqrt(d_k)
masked_fill
softmax
@ V
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正实现就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def scaled_dot_product_attention(q, k, v, mask=None):
    d_k = k.size(-1)
    scores = q @ k.transpose(-2, -1) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask, float(&quot;-inf&quot;))
    attn = F.softmax(scores, dim=-1)
    return attn @ v
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一段如果 shape 能看懂，Transformer 就已经通了一半。&lt;/p&gt;
&lt;h2&gt;6. Multi-Head Attention 真正增加了什么&lt;/h2&gt;
&lt;p&gt;多头注意力的重点，不只是“多做几次 attention”，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先把同一个表示投影到不同子空间&lt;/li&gt;
&lt;li&gt;每个头学不同的关注模式&lt;/li&gt;
&lt;li&gt;最后再拼回来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;原代码里把这条 shape 变化写得很清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[B, S, D]
→ [B, S, H, Dh]
→ [B, H, S, Dh]
→ attention
→ [B, S, D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是理解多头机制最值得反复看的地方。&lt;/p&gt;
&lt;h2&gt;7. Encoder / Decoder 是怎么组起来的&lt;/h2&gt;
&lt;p&gt;这份实现保持了 Transformer 最经典的结构：&lt;/p&gt;
&lt;h3&gt;EncoderLayer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;self-attention&lt;/li&gt;
&lt;li&gt;residual + layer norm&lt;/li&gt;
&lt;li&gt;FFN&lt;/li&gt;
&lt;li&gt;residual + layer norm&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;DecoderLayer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;masked self-attention&lt;/li&gt;
&lt;li&gt;cross-attention&lt;/li&gt;
&lt;li&gt;FFN&lt;/li&gt;
&lt;li&gt;每段后都有 residual + layer norm&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时候 Transformer 就不再神秘了，它就是把这些标准模块一层层堆起来。&lt;/p&gt;
&lt;h2&gt;8. 用 toy task 跑通：反转序列&lt;/h2&gt;
&lt;p&gt;我很喜欢这份代码没有直接上复杂任务，而是先做了一个最小的可验证任务：&lt;br /&gt;
把输入序列反转。&lt;/p&gt;
&lt;p&gt;数据构造函数也写得很清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def generate_reverse_data(batch_size, content_len, vocab_size, pad_id=0, bos_id=1, eos_id=2):
    content = torch.randint(3, vocab_size, (batch_size, content_len))
    bos = torch.full((batch_size, 1), bos_id, dtype=torch.long)
    eos = torch.full((batch_size, 1), eos_id, dtype=torch.long)
    src = torch.concat((bos, content, eos), dim=1)
    reversed_content = torch.flip(content, dims=[1])
    tgt = torch.concat((bos, reversed_content, eos), dim=1)
    tgt_input = tgt[:, :-1]
    tgt_output = tgt[:, 1:]
    return src, tgt_input, tgt_output
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段非常适合理解 seq2seq 训练里的两个关键点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tgt_input&lt;/code&gt; 和 &lt;code&gt;tgt_output&lt;/code&gt; 是错位的&lt;/li&gt;
&lt;li&gt;Decoder 训练时吃的是前一个位置的真实 token&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;9. 训练循环和 greedy decode&lt;/h2&gt;
&lt;p&gt;最后这段代码把完整流程跑通了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;训练时：
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;src -&amp;gt; encoder&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tgt_input -&amp;gt; decoder&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;logits -&amp;gt; CrossEntropyLoss&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;推理时：
&lt;ul&gt;
&lt;li&gt;从 &lt;code&gt;BOS&lt;/code&gt; 开始&lt;/li&gt;
&lt;li&gt;每次取最后一个位置的 logits&lt;/li&gt;
&lt;li&gt;贪心生成下一个 token&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就是最小版的 seq2seq 生成闭环。&lt;/p&gt;
&lt;h2&gt;10. 这一阶段该记住什么&lt;/h2&gt;
&lt;p&gt;如果只保留最少几句话：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Transformer 不是黑盒，它是多个标准模块的组合。&lt;/li&gt;
&lt;li&gt;位置编码、mask、多头注意力是最关键的三个部件。&lt;/li&gt;
&lt;li&gt;理解 shape 变化，比死背公式更重要。&lt;/li&gt;
&lt;li&gt;一个 toy task 足够把整条训练与推理链跑通。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我觉得这份手写实现最有价值的地方，不是“性能”，而是它把 Transformer 变成了一套可以亲手拆开的积木。&lt;/p&gt;
</content:encoded></item><item><title>Milvus 入门：集合、索引与检索流程</title><link>https://owen571.top/posts/study/rag/05-milvus-%E5%9F%BA%E7%A1%80%E4%B8%8E%E7%B4%A2%E5%BC%95%E6%A3%80%E7%B4%A2/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/05-milvus-%E5%9F%BA%E7%A1%80%E4%B8%8E%E7%B4%A2%E5%BC%95%E6%A3%80%E7%B4%A2/</guid><description>把 Milvus 里最常用的对象和流程串起来：部署、schema、collection、index、load 与 search。</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;前面已经把向量数据库的通用心智搭起来了，这一篇就落到 Milvus 本身，理解它在真实系统里是怎么组织数据和执行搜索的。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 向量数据库与Milvus基础知识&lt;/h1&gt;
&lt;h2&gt;1. 简介&lt;/h2&gt;
&lt;p&gt;Milvus 是一个开源的、专为大规模向量相似性搜索和分析而设计的向量数据库。它诞生于 Zilliz 公司，并已成为 LF AI &amp;amp; Data 基金会的顶级项目，在AI领域拥有广泛的应用。&lt;/p&gt;
&lt;p&gt;与 FAISS、ChromaDB 等轻量级本地存储方案不同，Milvus 从设计之初就瞄准了生产环境。其采用云原生架构，具备高可用、高性能、易扩展的特性，能够处理十亿、百亿甚至更大规模的向量数据。&lt;/p&gt;
&lt;p&gt;官网地址: https://milvus.io/&lt;/p&gt;
&lt;p&gt;GitHub: https://github.com/milvus-io/milvus&lt;/p&gt;
&lt;h2&gt;2. 用Docker部署安装&lt;/h2&gt;
&lt;p&gt;Docker的使用我另有总结，如果还不熟练可以先看那边复习。&lt;/p&gt;
&lt;p&gt;我们可以直接拉下来官方的docker-compose.yml，用&lt;code&gt;wget https://github.com/milvus-io/milvus/releases/download/v2.5.14/milvus-standalone-docker-compose.yml -O docker-compose.yml&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;我们瞅一眼文件内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;version: &apos;3.5&apos;

services:
  etcd:
    container_name: milvus-etcd
    image: quay.io/coreos/etcd:v3.5.18
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
      - ETCD_SNAPSHOT_COUNT=50000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    healthcheck:
      test: [&quot;CMD&quot;, &quot;etcdctl&quot;, &quot;endpoint&quot;, &quot;health&quot;]
      interval: 30s
      timeout: 20s
      retries: 3

  minio:
    container_name: milvus-minio
    image: minio/minio:RELEASE.2024-05-28T17-19-04Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - &quot;9001:9001&quot;
      - &quot;9000:9000&quot;
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data --console-address &quot;:9001&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:9000/minio/health/live&quot;]
      interval: 30s
      timeout: 20s
      retries: 3

  standalone:
    container_name: milvus-standalone
    image: milvusdb/milvus:v2.5.14
    command: [&quot;milvus&quot;, &quot;run&quot;, &quot;standalone&quot;]
    security_opt:
      - seccomp:unconfined
    environment:
      MINIO_REGION: us-east-1
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:9091/healthz&quot;]
      interval: 30s
      start_period: 90s
      timeout: 20s
      retries: 3
    ports:
      - &quot;19530:19530&quot;
      - &quot;9091:9091&quot;
    depends_on:
      - &quot;etcd&quot;
      - &quot;minio&quot;

networks:
  default:
    name: milvus
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以看到，Docker 将会自动拉取所需的镜像并启动三个容器：milvus-standalone, milvus-minio, 和 milvus-etcd，其中前者依赖于后两者的，然后将他们放入同一子网起名为milvus。然后注意，standalone通过19530和9091端口提供服务。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-14.K_soWaii.png&amp;amp;w=2452&amp;amp;h=300&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;3. Milvus的核心组件 - Collection&lt;/h2&gt;
&lt;p&gt;用一个图书馆例子来比喻Collection的存储方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Collection (集合): 相当于一个图书馆，是所有数据的顶层容器。一个 Collection 可以包含多个 Partition，每个 Partition 可以包含多个 Entity。&lt;/li&gt;
&lt;li&gt;Partition (分区): 相当于图书馆里的不同区域（如“小说区”、“科技区”），将数据物理隔离，让检索更高效。&lt;/li&gt;
&lt;li&gt;Schema (模式): 相当于图书馆的图书卡片规则，定义了每本书（数据）必须登记哪些信息（字段）。&lt;/li&gt;
&lt;li&gt;Entity (实体): 相当于一本具体的书，是数据本身。&lt;/li&gt;
&lt;li&gt;Alias (别名): 相当于一个动态的推荐书单（如“本周精选”），它可以指向某个具体的 Collection，方便应用层调用，实现数据更新时的无缝切换。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) Collection&lt;/h3&gt;
&lt;p&gt;Collection是Milvus中的最基本数据组织单位，类似于关系型数据库里面的Table。是我们存储、管理和查询向量及相关元数据的容器。所有的数据操作，如插入、删除、查询等，都是围绕 Collection 展开的。&lt;/p&gt;
&lt;p&gt;一个Collection由Schema定义&lt;/p&gt;
&lt;h3&gt;(2) Schema&lt;/h3&gt;
&lt;p&gt;在创建 Collection 之前，必须先定义它的 Schema。 Schema 规定了 Collection 的数据结构，定义了其中包含的所有字段 (Field) 及其属性。一个设计良好的 Schema 是能够保证数据一致性并提升查询性能。&lt;/p&gt;
&lt;p&gt;Schema 通常包含以下几类字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;主键字段 (Primary Key Field): 每个 Collection 必须有且仅有一个主键字段，用于唯一标识每一条数据（实体）。它的值必须是唯一的，通常是整数或字符串类型。&lt;/li&gt;
&lt;li&gt;向量字段 (Vector Field): 用于存储核心的向量数据。一个 Collection 可以有一个或多个向量字段，以满足多模态等复杂场景的需求。&lt;/li&gt;
&lt;li&gt;标量字段 (Scalar Field): 用于存储除向量之外的元数据，如字符串、数字、布尔值、JSON 等。这些字段可以用于过滤查询，实现更精确的检索。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-15.B7hT2QLB.png&amp;amp;w=1280&amp;amp;h=571&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;上图以一篇新闻文章为例，展示了一个典型的多模态、混合向量 Schema 设计。它将一篇文章拆解为：唯一的 Article (ID)、文本元数据（如 Title、Author Info）、图像信息（Image URL），并为图像和摘要内容分别生成了密集向量（Image Embedding, Summary Embedding）和稀疏向量（Summary Sparse Embedding）。&lt;/p&gt;
&lt;p&gt;我们来看看，常见的字段有哪些，他们的作用又是什么：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段类型&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;例子&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BOOL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;布尔值&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true/false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INT8/16/32/64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;整数&lt;/td&gt;
&lt;td&gt;年份、数量、ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FLOAT/DOUBLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;小数&lt;/td&gt;
&lt;td&gt;分数、价格、概率&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VARCHAR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;字符串&lt;/td&gt;
&lt;td&gt;标题、类别、作者&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JSON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;结构化对象&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{&quot;author&quot;:&quot;Tom&quot;,&quot;year&quot;:2024}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ARRAY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;数组&lt;/td&gt;
&lt;td&gt;标签列表、多个分类&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FLOAT_VECTOR&lt;/code&gt; 等&lt;/td&gt;
&lt;td&gt;向量&lt;/td&gt;
&lt;td&gt;文本 embedding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(3) Partition&lt;/h3&gt;
&lt;p&gt;Partition 是 Collection 内部的一个逻辑划分。每个 Collection 在创建时都会有一个名为 _default 的默认分区。我们可以根据业务需求创建更多的分区，将数据按特定规则（如类别、日期等）存入不同分区。&lt;/p&gt;
&lt;p&gt;为什么使用分区？&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;提升查询性能: 在查询时，可以指定只在一个或几个分区内进行搜索，从而大幅减少需要扫描的数据量，显著提升检索速度。&lt;/li&gt;
&lt;li&gt;数据管理: 便于对部分数据进行批量操作，如加载/卸载特定分区到内存，或者删除整个分区的数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一个 Collection 最多可以有 1024 个分区。合理利用分区是 Milvus 性能优化的重要手段之一。&lt;/p&gt;
&lt;h3&gt;(4) Alias&lt;/h3&gt;
&lt;p&gt;Alias (别名) 是为 Collection 提供的一个“昵称”。通过为一个 Collection 设置别名，我们可以在应用程序中使用这个别名来执行所有操作，而不是直接使用真实的 Collection 名称。&lt;/p&gt;
&lt;p&gt;为什么使用别名？&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;安全地更新数据：想象一下，你需要对一个在线服务的 Collection 进行大规模的数据更新或重建索引。直接在原 Collection 上操作风险很高。正确的做法是：
&lt;ol&gt;
&lt;li&gt;创建一个新的 Collection (collection_v2) 并导入、索引好所有新数据。&lt;/li&gt;
&lt;li&gt;将指向旧 Collection (collection_v1) 的别名（例如 my_app_collection）原子性地切换到新 Collection (collection_v2) 上。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;代码解耦：整个切换过程对上层应用完全透明，无需修改任何代码或重启服务，实现了数据的平滑无缝升级。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. Milvus的核心组件 - 索引 (Index)&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;https://milvus.io/docs/zh/index-explained.md&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;如果说 Collection 是 Milvus 的骨架，那么索引 (Index) 就是其加速检索的神经系统。从宏观上看，索引本身就是一种为了加速查询而设计的复杂数据结构。对向量数据创建索引后，Milvus 可以极大地提升向量相似性搜索的速度，代价是会占用额外的存储和内存资源。&lt;/p&gt;
&lt;p&gt;如下图所示，Milvus 中的索引类型由三个核心部分组成，即数据结构、量化和细化器。量化和精炼器是可选的，但由于收益大于成本的显著平衡而被广泛使用。&lt;/p&gt;
&lt;p&gt;在创建索引时，Milvus 会结合所选的数据结构和量化方法来确定最佳扩展率。在查询时，系统会检索topK × expansion rate 候选向量，应用精炼器以更高的精度重新计算距离，最后返回最精确的topK 结果。这种混合方法通过将资源密集型细化限制在候选矢量的过滤子集上，在速度和精确度之间取得了平衡。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-16.aUeWhv31.png&amp;amp;w=2560&amp;amp;h=1912&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据结构：数据结构是索引的基础层，常见类型包括反转文件（IVF）和基于图的结构（比如HNSW）。&lt;/li&gt;
&lt;li&gt;量化(可选)：数据压缩技术，通过降低向量精度来减少内存占用和加速计算。有标量量化（如SQ8）和乘积量化（PQ）。
&lt;ul&gt;
&lt;li&gt;这里简单补充一下，SQ和PQ都是向量压缩/量化技术，但是SQ是把每个维度都单独压缩，而PQ是把整个向量切成多个子向量，每个子向量聚类后在codebook中找一个最接近的值，然后只保存这个值的标号。&lt;/li&gt;
&lt;li&gt;codebook是从真实向量中聚类学出来的（通常是K-means），每个聚类中心会成为一个codebook entry。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[原始训练向量集合] --&amp;gt; B[将每个向量切分为多个子向量]
    B --&amp;gt; C1[子空间 1 的所有子向量]
    B --&amp;gt; C2[子空间 2 的所有子向量]
    B --&amp;gt; C3[子空间 3 的所有子向量]
    B --&amp;gt; C4[子空间 4 的所有子向量]

    C1 --&amp;gt; D1[对子空间 1 做 K-Means 聚类]
    C2 --&amp;gt; D2[对子空间 2 做 K-Means 聚类]
    C3 --&amp;gt; D3[对子空间 3 做 K-Means 聚类]
    C4 --&amp;gt; D4[对子空间 4 做 K-Means 聚类]

    D1 --&amp;gt; E1[得到 Codebook 1&amp;lt;br/&amp;gt;若干聚类中心]
    D2 --&amp;gt; E2[得到 Codebook 2&amp;lt;br/&amp;gt;若干聚类中心]
    D3 --&amp;gt; E3[得到 Codebook 3&amp;lt;br/&amp;gt;若干聚类中心]
    D4 --&amp;gt; E4[得到 Codebook 4&amp;lt;br/&amp;gt;若干聚类中心]

    F[一个新的原始向量] --&amp;gt; G[切分成多个子向量]

    G --&amp;gt; H1[子向量 1]
    G --&amp;gt; H2[子向量 2]
    G --&amp;gt; H3[子向量 3]
    G --&amp;gt; H4[子向量 4]

    E1 --&amp;gt; I1[在 Codebook 1 中寻找最近中心]
    E2 --&amp;gt; I2[在 Codebook 2 中寻找最近中心]
    E3 --&amp;gt; I3[在 Codebook 3 中寻找最近中心]
    E4 --&amp;gt; I4[在 Codebook 4 中寻找最近中心]

    H1 --&amp;gt; I1
    H2 --&amp;gt; I2
    H3 --&amp;gt; I3
    H4 --&amp;gt; I4

    I1 --&amp;gt; J1[记录编号 id1]
    I2 --&amp;gt; J2[记录编号 id2]
    I3 --&amp;gt; J3[记录编号 id3]
    I4 --&amp;gt; J4[记录编号 id4]

    J1 --&amp;gt; K[PQ 编码结果&amp;lt;br/&amp;gt;id1, id2, id3, id4]
    J2 --&amp;gt; K
    J3 --&amp;gt; K
    J4 --&amp;gt; K

    K --&amp;gt; L[存储时只保存编号&amp;lt;br/&amp;gt;大幅减少存储空间]

    K --&amp;gt; M[检索时根据编号回查各个 Codebook]
    E1 --&amp;gt; M
    E2 --&amp;gt; M
    E3 --&amp;gt; M
    E4 --&amp;gt; M

    M --&amp;gt; N[拼接出近似向量]
    N --&amp;gt; O[用于近似距离计算与 ANN 检索]

    P[核心思想] --&amp;gt; Q[不用保存原始浮点向量]
    P --&amp;gt; R[只保存每个子向量最接近的中心编号]
    P --&amp;gt; S[用少量精度损失换取更高压缩率与检索效率]

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;结果精炼(可选)：量化本身就是有损的。为了保持召回率，量化始终会产生比所需数量更多的前 K 个候选结果，这使得精炼器可以使用更高的精度从这些候选结果中进一步选择前 K 个结果，从而提高召回率。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Milvus 支持对标量字段和向量字段分别创建索引。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标量字段索引：主要用于加速元数据过滤，对于标量字段，始终使用推荐的索引类型即可。&lt;/li&gt;
&lt;li&gt;向量字段索引：这是 Milvus 的核心。选择合适的向量索引是在查询性能、召回率和内存占用之间做出权衡的艺术。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在给出字段数据类型与适用索引类型之间的适应关系：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段数据类型&lt;/th&gt;
&lt;th&gt;适用索引类型&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FLOAT_VECTOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;平面、&lt;code&gt;IVF_FLAT&lt;/code&gt;、&lt;code&gt;IVF_SQ8&lt;/code&gt;、&lt;code&gt;IVF_PQ&lt;/code&gt;、&lt;code&gt;IVF_RABITQ&lt;/code&gt;、&lt;code&gt;HNSW&lt;/code&gt;、&lt;code&gt;HNSW_SQ&lt;/code&gt;、&lt;code&gt;HNSW_PQ&lt;/code&gt;、&lt;code&gt;HNSW_PRQ&lt;/code&gt;、&lt;code&gt;DISKANN&lt;/code&gt;、&lt;code&gt;SCANN&lt;/code&gt;、&lt;code&gt;AISAQ&lt;/code&gt;、&lt;code&gt;GPU_CAGRA&lt;/code&gt;、&lt;code&gt;GPU_IVF_FLAT&lt;/code&gt;、&lt;code&gt;GPU_IVF_PQ&lt;/code&gt;、&lt;code&gt;GPU_BRUT_FORCE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FLOAT16_VECTOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;平面、&lt;code&gt;IVF_FLAT&lt;/code&gt;、&lt;code&gt;IVF_SQ8&lt;/code&gt;、&lt;code&gt;IVF_PQ&lt;/code&gt;、&lt;code&gt;IVF_RABITQ&lt;/code&gt;、&lt;code&gt;HNSW&lt;/code&gt;、&lt;code&gt;HNSW_SQ&lt;/code&gt;、&lt;code&gt;HNSW_PQ&lt;/code&gt;、&lt;code&gt;HNSW_PRQ&lt;/code&gt;、&lt;code&gt;DISKANN&lt;/code&gt;、&lt;code&gt;SCANN&lt;/code&gt;、&lt;code&gt;AISAQ&lt;/code&gt;、&lt;code&gt;GPU_CAGRA&lt;/code&gt;、&lt;code&gt;GPU_IVF_FLAT&lt;/code&gt;、&lt;code&gt;GPU_IVF_PQ&lt;/code&gt;、&lt;code&gt;GPU_BRUT_FORCE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BFLOAT16_VECTOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;平面、&lt;code&gt;IVF_FLAT&lt;/code&gt;、&lt;code&gt;IVF_SQ8&lt;/code&gt;、&lt;code&gt;IVF_PQ&lt;/code&gt;、&lt;code&gt;IVF_RABITQ&lt;/code&gt;、&lt;code&gt;HNSW&lt;/code&gt;、&lt;code&gt;HNSW_SQ&lt;/code&gt;、&lt;code&gt;HNSW_PQ&lt;/code&gt;、&lt;code&gt;HNSW_PRQ&lt;/code&gt;、&lt;code&gt;DISKANN&lt;/code&gt;、&lt;code&gt;SCANN&lt;/code&gt;、&lt;code&gt;AISAQ&lt;/code&gt;、&lt;code&gt;GPU_CAGRA&lt;/code&gt;、&lt;code&gt;GPU_IVF_FLAT&lt;/code&gt;、&lt;code&gt;GPU_IVF_PQ&lt;/code&gt;、&lt;code&gt;GPU_BRUT_FORCE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INT8_VECTOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;平面、&lt;code&gt;IVF_FLAT&lt;/code&gt;、&lt;code&gt;IVF_SQ8&lt;/code&gt;、&lt;code&gt;IVF_PQ&lt;/code&gt;、&lt;code&gt;IVF_RABITQ&lt;/code&gt;、&lt;code&gt;HNSW&lt;/code&gt;、&lt;code&gt;HNSW_SQ&lt;/code&gt;、&lt;code&gt;HNSW_PQ&lt;/code&gt;、&lt;code&gt;HNSW_PRQ&lt;/code&gt;、&lt;code&gt;DISKANN&lt;/code&gt;、&lt;code&gt;SCANN&lt;/code&gt;、&lt;code&gt;AISAQ&lt;/code&gt;、&lt;code&gt;GPU_CAGRA&lt;/code&gt;、&lt;code&gt;GPU_IVF_FLAT&lt;/code&gt;、&lt;code&gt;GPU_IVF_PQ&lt;/code&gt;、&lt;code&gt;GPU_BRUT_FORCE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;二进制向量&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BIN_FLAT&lt;/code&gt;、&lt;code&gt;BIN_IVF_FLAT&lt;/code&gt;、&lt;code&gt;MINHASH_LSH&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;稀疏浮点矢量&lt;/td&gt;
&lt;td&gt;稀疏反转索引&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VARCHAR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转（推荐）、&lt;code&gt;BITMAP&lt;/code&gt;、三角形&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BOOL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BITMAP&lt;/code&gt;（推荐）、反转&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INT8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转、&lt;code&gt;STL_SORT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INT16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转、&lt;code&gt;STL_SORT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INT32&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转、&lt;code&gt;STL_SORT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INT64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转、&lt;code&gt;STL_SORT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FLOAT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DOUBLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数组（&lt;code&gt;BOOL&lt;/code&gt;、&lt;code&gt;INT8/16/32/64&lt;/code&gt; 和 &lt;code&gt;VARCHAR&lt;/code&gt; 类型的元素）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BITMAP&lt;/code&gt;（推荐）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数组（&lt;code&gt;BOOL&lt;/code&gt;、&lt;code&gt;INT8/16/32/64&lt;/code&gt;、&lt;code&gt;FLOAT&lt;/code&gt;、&lt;code&gt;DOUBLE&lt;/code&gt; 和 &lt;code&gt;VARCHAR&lt;/code&gt; 类型的元素）&lt;/td&gt;
&lt;td&gt;反转&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JSON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;反转&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;(1) 索引算法 - 标量&lt;/h3&gt;
&lt;p&gt;前面提到，对于标量索引，直接用推荐值即可。这里学习一下标量索引是怎么做的：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;标量索引方法&lt;/th&gt;
&lt;th&gt;具体解释&lt;/th&gt;
&lt;th&gt;主要用处&lt;/th&gt;
&lt;th&gt;适合字段&lt;/th&gt;
&lt;th&gt;优点 / 局限&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;反转索引（Inverted Index）&lt;/td&gt;
&lt;td&gt;为每个字段值维护一个倒排表，记录“这个值出现在哪些记录里”&lt;/td&gt;
&lt;td&gt;等值过滤、关键词过滤、多条件筛选&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR&lt;/code&gt;、&lt;code&gt;JSON&lt;/code&gt;、&lt;code&gt;ARRAY&lt;/code&gt;、部分数值字段&lt;/td&gt;
&lt;td&gt;优点：等值查询快，适合文本和标签类过滤。局限：对连续数值范围查询通常不如排序类索引直接&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BITMAP 索引&lt;/td&gt;
&lt;td&gt;用位图记录某个值在每条记录中是否出现，1 表示有，0 表示无&lt;/td&gt;
&lt;td&gt;低基数字段过滤、多条件组合查询&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BOOL&lt;/code&gt;、枚举类字段、部分数组字段&lt;/td&gt;
&lt;td&gt;优点：集合交并运算非常快。局限：字段取值特别多时不划算&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STL_SORT&lt;/td&gt;
&lt;td&gt;按字段值排序保存，查询时通过范围定位快速找到满足条件的记录&lt;/td&gt;
&lt;td&gt;数值范围查询、排序、区间筛选&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INT8&lt;/code&gt;、&lt;code&gt;INT16&lt;/code&gt;、&lt;code&gt;INT32&lt;/code&gt;、&lt;code&gt;INT64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;优点：范围查询高效。局限：更偏数值型场景，不适合复杂文本检索&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;有些地方解释一下。首先，反转索引的具体做法，其实就是维持一个“值 -&amp;gt; 文档列表”的索引。比如文档1、3的类型是medical，就会medical-&amp;gt;[1,3]，查询的时候会直接那这个列表，不用扫记录；如果是多条件，比如AND，那就做交集。&lt;/p&gt;
&lt;p&gt;位图，学过OS的应该很熟悉，就是将值对应到一串0/1。&lt;/p&gt;
&lt;p&gt;STL_SORT是先将字段值排好序，然后直接用二分查找做范围过滤，适合数值字段。查大于等于这样的数字也是先二分查这个数字的左边界，然后从这里往后查。&lt;/p&gt;
&lt;p&gt;稀疏反转索引&lt;/p&gt;
&lt;h3&gt;(2) 索引算法 - 向量&lt;/h3&gt;
&lt;p&gt;Milvus 提供了多种向量索引算法，以适应不同的应用场景。以下是几种最核心的类型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;FLAT (精确查找)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原理：暴力搜索（Brute-force Search）。它会计算查询向量与集合中所有向量之间的实际距离，返回最精确的结果。&lt;/li&gt;
&lt;li&gt;优点：100% 的召回率，结果最准确。&lt;/li&gt;
&lt;li&gt;缺点：速度慢，内存占用大，不适合海量数据。&lt;/li&gt;
&lt;li&gt;适用场景：对精度要求极高，且数据规模较小（百万级以内）的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IVF 系列 (倒排文件索引)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原理：类似于书籍的目录。它首先通过聚类将所有向量分成多个“桶”(nlist)，查询时，先找到最相似的几个“桶”，然后只在这几个桶内进行精确搜索。IVF_FLAT、IVF_SQ8、IVF_PQ 是其不同变体，主要区别在于是否对桶内向量进行了压缩（量化）。&lt;/li&gt;
&lt;li&gt;优点：通过缩小搜索范围，极大地提升了检索速度，是性能和效果之间很好的平衡。&lt;/li&gt;
&lt;li&gt;缺点：召回率不是100%，因为相关向量可能被分到了未被搜索的桶中。&lt;/li&gt;
&lt;li&gt;适用场景：通用场景，尤其适合需要高吞吐量的大规模数据集。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;以下分别是文件和向量的倒排索引，向量的IVF吸取了文件的思想
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-20.DAmqTtpz.png&amp;amp;w=1200&amp;amp;h=610&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-21.DGRN6GVT.png&amp;amp;w=1366&amp;amp;h=606&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HNSW (Hierarchical Navigable Small Worlds，分层-可导航-小世界-图，是一种基于图的索引)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原理：构建一个多层的邻近图。查询时从最上层的稀疏图开始，快速定位到目标区域，然后在下层的密集图中进行精确搜索。&lt;/li&gt;
&lt;li&gt;优点：检索速度极快，召回率高，尤其擅长处理高维数据和低延迟查询。&lt;/li&gt;
&lt;li&gt;缺点：内存占用非常大，构建索引的时间也较长。&lt;/li&gt;
&lt;li&gt;适用场景：对查询延迟有严格要求（如实时推荐、在线搜索）的场景。
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-22.DrDfK5ku.png&amp;amp;w=906&amp;amp;h=488&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-23.6Ty_yda9.png&amp;amp;w=1300&amp;amp;h=616&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;DiskANN (基于磁盘的索引)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原理：一种为在 SSD 等高速磁盘上运行而优化的图索引。&lt;/li&gt;
&lt;li&gt;优点：支持远超内存容量的海量数据集（十亿级甚至更多），同时保持较低的查询延迟。&lt;/li&gt;
&lt;li&gt;缺点：相比纯内存索引，延迟稍高。&lt;/li&gt;
&lt;li&gt;适用场景：数据规模巨大，无法全部加载到内存的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) 索引算法 - 性能均衡&lt;/h3&gt;
&lt;p&gt;除了暴力搜索能精确索引到近邻，所有搜索算法只能在性能、召回率、内存三者之间权衡。&lt;/p&gt;
&lt;p&gt;在评估性能的时候，平衡构建时间、每秒查询次数（QPS）和召回率至关重要，一般性的规则如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;就QPS 而言，基于图形的索引类型通常优于IVF 变体。&lt;/li&gt;
&lt;li&gt;IVF 变体尤其适用于topK 较大的情况（例如，超过 2,000 个）。&lt;/li&gt;
&lt;li&gt;与SQ相比，PQ通常能在相似的压缩率下提供更好的召回率，但后者的性能更快。&lt;/li&gt;
&lt;li&gt;将硬盘用于部分索引（如DiskANN）有助于管理大型数据集，但也会带来潜在的 IOPS 瓶颈。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;另外，根据处理容量问题的时候，要考虑以下几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果有四分之一的原始数据适合存储在内存中，则应考虑使用延迟稳定的 DiskANN。&lt;/li&gt;
&lt;li&gt;如果所有原始数据都适合在内存中存储，则应考虑基于内存的索引类型和 mmap。&lt;/li&gt;
&lt;li&gt;可以使用量化应用索引类型和 mmap 来换取最大容量的准确性。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从召回率考虑，召回率涉及过滤率，即搜索前过滤掉的数据。处理召回问题，应考虑以下几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果过滤率小于 85%，则基于图的索引类型优于 IVF 变体。&lt;/li&gt;
&lt;li&gt;如果过滤比在 85% 到 95% 之间，则使用 IVF 变体。&lt;/li&gt;
&lt;li&gt;如果过滤率超过 98%，则使用 &quot;蛮力&quot;（FLAT）来获得最准确的搜索结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从性能考虑，搜索性能通常涉及top-K，即搜索返回记录数。处理性能时会考虑以下问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对于 Top-K 较小的搜索（如 2,000），需要较高的召回率，基于图的索引类型优于 IVF 变体。&lt;/li&gt;
&lt;li&gt;对于 top-K 较大的搜索（与向量嵌入的总数相比），IVF 变体比基于图的索引类型是更好的选择。&lt;/li&gt;
&lt;li&gt;对于 top-K 中等且过滤率较高的搜索，IVF 变体是更好的选择。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后总结一下决策矩阵：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;推荐索引&lt;/th&gt;
&lt;th&gt;注释&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;原始数据适合内存&lt;/td&gt;
&lt;td&gt;HNSW、IVF + 精炼&lt;/td&gt;
&lt;td&gt;使用 HNSW 实现低 k / 高召回率&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;磁盘、固态硬盘上的原始数据&lt;/td&gt;
&lt;td&gt;磁盘 ANN&lt;/td&gt;
&lt;td&gt;最适合对延迟敏感的查询&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;磁盘上的原始数据，有限的 RAM&lt;/td&gt;
&lt;td&gt;IVFPQ / SQ + mmap&lt;/td&gt;
&lt;td&gt;平衡内存和磁盘访问&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;高过滤率（&amp;gt;95%）&lt;/td&gt;
&lt;td&gt;强制（FLAT）&lt;/td&gt;
&lt;td&gt;避免微小候选集的索引开销&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;大型 k（≥ 数据集的 1%）&lt;/td&gt;
&lt;td&gt;IVF&lt;/td&gt;
&lt;td&gt;簇剪枝减少了计算量&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;极高的召回率（&amp;gt;99%）&lt;/td&gt;
&lt;td&gt;蛮力（FLAT）+ GPU&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;5. Milvus的核心组件 - 检索 (Search)&lt;/h2&gt;
&lt;p&gt;拥有了数据容器 (Collection) 和检索引擎 (Index) 后，最后一步就是从海量数据中高效地检索信息。这是 Milvus 的核心功能之一，近似最近邻 (Approximate Nearest Neighbor, ANN) 检索。与需要计算全部数据的暴力检索（Brute-force Search）不同，ANN 检索利用预先构建好的索引，能够极速地从海量数据中找到与查询向量最相似的 Top-K 个结果。这是一种在速度和精度之间取得极致平衡的策略。&lt;/p&gt;
&lt;p&gt;主要参数:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;anns_field: 指定要在哪个向量字段上进行检索。&lt;/li&gt;
&lt;li&gt;data: 传入一个或多个查询向量。&lt;/li&gt;
&lt;li&gt;limit (或 top_k): 指定需要返回的最相似结果的数量。&lt;/li&gt;
&lt;li&gt;search_params: 指定检索时使用的参数，例如距离计算方式 (metric_type) 和索引相关的查询参数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ANN通常是一种思想而不是算法，前文中向量字段索引算法除了FLAT，IVF、HNSW、DiskANN都是ANN，还有很多种。&lt;/p&gt;
&lt;p&gt;在基础ANN检索之上，Milvus还提供了多种增强检索功能，以满足更加复杂的业务需求。&lt;/p&gt;
&lt;h3&gt;(1) 过滤检索 (Filtered Search)&lt;/h3&gt;
&lt;p&gt;在实际应用中，我们很少只进行单纯的向量检索。更常见的需求是“在满足特定条件的向量中，查找最相似的结果”，这就是过滤检索。它将向量相似性检索与标量字段过滤结合在一起。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工作原理：先根据提供的过滤表达式 (filter) 筛选出符合条件的实体，然后仅在这个子集内执行 ANN 检索。这极大地提高了查询的精准度。&lt;/li&gt;
&lt;li&gt;应用示例：
&lt;ul&gt;
&lt;li&gt;电商：&quot;检索与这件红色连衣裙最相似的商品，但只看价格低于500元且有库存的。&quot;&lt;/li&gt;
&lt;li&gt;知识库：&quot;查找与‘人工智能’相关的文档，但只从‘技术’分类下、且发布于2023年之后的文章中寻找。&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(2) 范围检索 (Range Search)&lt;/h3&gt;
&lt;p&gt;有时我们关心的不是最相似的 Top-K 个结果，而是“所有与查询向量的相似度在特定范围内的结果”。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工作原理：范围检索允许定义一个距离（或相似度）的阈值范围。Milvus 会返回所有与查询向量的距离落在这个范围内的实体。&lt;/li&gt;
&lt;li&gt;应用示例：
&lt;ul&gt;
&lt;li&gt;人脸识别：&quot;查找所有与目标人脸相似度超过 0.9 的人脸&quot;，用于身份验证。&lt;/li&gt;
&lt;li&gt;异常检测：&quot;查找所有与正常样本向量距离过大的数据点&quot;，用于发现异常。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) 多向量混合检索 (Hybrid Search)&lt;/h3&gt;
&lt;p&gt;这是 Milvus 提供的一种极其强大的高级检索模式，它允许在一个请求中同时检索多个向量字段，并将结果智能地融合在一起。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;工作原理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;并行检索：应用针对不同的向量字段（如一个用于文本语义的密集向量，一个用于关键词匹配的稀疏向量，一个用于图像内容的多模态向量）分别发起 ANN 检索请求。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结果融合 (Rerank)&lt;/strong&gt;：Milvus 使用一个重排策略（Reranker）将来自不同检索流的结果合并成一个统一的、更高质量的排序列表。常用的策略有 RRFRanker（平衡各方结果）和 WeightedRanker（可为特定字段结果加权）。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;应用示例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多模态商品检索：用户输入文本“安静舒适的白色耳机”，系统可以同时检索商品的文本描述向量和图片内容向量，返回最匹配的商品。&lt;/li&gt;
&lt;li&gt;增强型 RAG: 结合密集向量（捕捉语义）和稀疏向量（精确匹配关键词），实现比单一向量更精准的文档检索效果。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(4) 分组检索 (Grouping Search)&lt;/h3&gt;
&lt;p&gt;分组检索解决了一个常见的痛点：检索结果多样性不足。想象一下，你检索“机器学习”，返回的前10篇文章都来自同一本教科书不同章节。这显然不是理想的结果。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工作原理：分组检索允许指定一个字段（如 document_id）对结果进行分组。Milvus 会在检索后，确保返回的结果中每个组（每个 document_id）只出现一次（或指定的次数），且返回的是该组内与查询最相似的那个实体。&lt;/li&gt;
&lt;li&gt;应用示例：
&lt;ul&gt;
&lt;li&gt;视频检索：检索“可爱的猫咪”，确保返回的视频来自不同的博主。&lt;/li&gt;
&lt;li&gt;文档检索：检索“数据库索引”，确保返回的结果来自不同的书籍或来源。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;通过这些灵活的检索功能组合，开发者可以构建出满足各种复杂业务需求的向量检索应用。&lt;/p&gt;
&lt;h2&gt;6. Milvus包的使用&lt;/h2&gt;
&lt;p&gt;上面讲了一大堆Milvus的概念和内容，但是没有讲操作Milvus的SDK，还没法上手使用。接下来就介绍一下PyMilvus的一些常用操作吧。文档在&lt;a href=&quot;https://milvus.io/docs/zh/install-pymilvus.md&quot;&gt;这里&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;首先，我们通过pip安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3 -m pip install pymilvus==2.6.10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装正确之后，我们就可以使用它的如下包：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API / 写法&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;常见参数&lt;/th&gt;
&lt;th&gt;什么时候用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;from pymilvus import MilvusClient, FieldSchema, CollectionSchema, DataType&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;导入客户端、Schema 和字段类型&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;开始写 PyMilvus 代码时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MilvusClient(uri=&quot;http://localhost:19530&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;连接 Milvus 服务&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uri&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;初始化客户端&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.has_collection(name)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;判断某个 collection 是否存在&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;创建前检查&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.drop_collection(name)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;删除 collection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;demo 重跑、清理旧数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FieldSchema(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;定义单个字段&lt;/td&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;、&lt;code&gt;dtype&lt;/code&gt;、&lt;code&gt;is_primary&lt;/code&gt;、&lt;code&gt;auto_id&lt;/code&gt;、&lt;code&gt;dim&lt;/code&gt;、&lt;code&gt;max_length&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自定义 schema 时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CollectionSchema(fields, description=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把多个字段组合成完整 schema&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fields&lt;/code&gt;、&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;创建 collection 前&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.create_collection(collection_name=..., schema=schema)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;按 schema 创建 collection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;、&lt;code&gt;schema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;建表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.describe_collection(collection_name=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看 collection 结构详情&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;验证建表结果&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.insert(collection_name=..., data=data)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插入数据&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;、&lt;code&gt;data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;入库向量和元数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.prepare_index_params()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;创建索引参数对象&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;建索引前准备&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index_params.add_index(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;向索引参数对象里添加一个索引定义&lt;/td&gt;
&lt;td&gt;&lt;code&gt;field_name&lt;/code&gt;、&lt;code&gt;index_type&lt;/code&gt;、&lt;code&gt;metric_type&lt;/code&gt;、&lt;code&gt;params&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;配置向量索引&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.create_index(collection_name=..., index_params=index_params)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;真正创建索引&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;、&lt;code&gt;index_params&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插入数据后建索引&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.describe_index(collection_name=..., index_name=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看索引详情&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;、&lt;code&gt;index_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;验证索引是否建好&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.load_collection(collection_name=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;将 collection 加载到内存，供检索使用&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;搜索前&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.search(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;执行向量检索&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;、&lt;code&gt;data&lt;/code&gt;、&lt;code&gt;limit&lt;/code&gt;、&lt;code&gt;output_fields&lt;/code&gt;、&lt;code&gt;search_params&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;真正做相似度搜索&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.release_collection(collection_name=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;从内存中释放 collection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collection_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;结束实验、释放资源&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;下面，我们也提供一个最小工作流，看一眼就理解这个向量数据库是怎么工作的了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pymilvus import MilvusClient, FieldSchema, CollectionSchema, DataType

# 1. 连接
client = MilvusClient(uri=&quot;http://localhost:19530&quot;)

# 2. 定义 schema
fields = [
    FieldSchema(name=&quot;id&quot;, dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name=&quot;vector&quot;, dtype=DataType.FLOAT_VECTOR, dim=768),
    FieldSchema(name=&quot;text&quot;, dtype=DataType.VARCHAR, max_length=512),
]
schema = CollectionSchema(fields, description=&quot;demo&quot;)

# 3. 创建 collection
client.create_collection(collection_name=&quot;demo&quot;, schema=schema)

# 4. 插入数据
data = [
    {&quot;vector&quot;: [0.1] * 768, &quot;text&quot;: &quot;hello&quot;},
    {&quot;vector&quot;: [0.2] * 768, &quot;text&quot;: &quot;world&quot;},
]
client.insert(collection_name=&quot;demo&quot;, data=data)

# 5. 建索引
index_params = client.prepare_index_params()
index_params.add_index(
    field_name=&quot;vector&quot;,
    index_type=&quot;HNSW&quot;,
    metric_type=&quot;COSINE&quot;,
    params={&quot;M&quot;: 16, &quot;efConstruction&quot;: 200}
)
client.create_index(collection_name=&quot;demo&quot;, index_params=index_params)

# 6. 加载 collection
client.load_collection(collection_name=&quot;demo&quot;)

# 7. 搜索
res = client.search(
    collection_name=&quot;demo&quot;,
    data=[[0.1] * 768],
    limit=2,
    output_fields=[&quot;text&quot;],
    search_params={&quot;metric_type&quot;: &quot;COSINE&quot;, &quot;params&quot;: {&quot;ef&quot;: 64}},
)
print(res)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上述代码，描述了建立一个Collection，包含id、vector和text，我们插入了两个全0.1和0.2的768维向量作为vector，然后给text写成hello、world。紧接着，我们创建索引，采用HNSW索引模式来索引向量字段，余弦相似度作为向量相似度度量，并用M=16、efConstruction=20作为建图参数（什么意思呢，就是每个节点做多连接16条邻居边，建索引时，为了给每个点找到更好的邻居，搜索候选集合开为200。合在一起就是建图的时候找考查200个候选点，然后找出真正合适的16个连边，200是一个经验值，M大图更密但召回更好，M小省资源）。&lt;/p&gt;
&lt;p&gt;然后，我们已经做好了向量数据库，就进行搜索。在demo中查询链表用一个768维的0.1的向量，返回两条结果（也就是top-2检索）。output_fields固定除了返回id、distance这些，还要把text字段返回（通常是元数据之类的），最后是搜索时用余弦相似度，然后ef说明了会维持64大小的候选集合再选出top-2.（ef越大搜索越充分，召回率通常更高，但是搜索更慢）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-18.3vkUI23X.png&amp;amp;w=1254&amp;amp;h=212&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;由于余弦相似度只看方向不看长度，所以它们和query的相似度都会接近于1（超过1一点通常是浮点误差），两个distance极度相近，所以排序排序谁前谁后都有可能。&lt;/p&gt;
</content:encoded></item><item><title>Off-Policy 偏好优化：DPO 与新分支</title><link>https://owen571.top/posts/study/reinforce-learning/07-off-policy-%E5%81%8F%E5%A5%BD%E4%BC%98%E5%8C%96-dpo/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/07-off-policy-%E5%81%8F%E5%A5%BD%E4%BC%98%E5%8C%96-dpo/</guid><description>从 PPO 的最优解视角回看偏好优化，理解 DPO 为什么能绕过显式奖励模型与强化学习流程。</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;DPO算法是对PPO的流程进一步简化,&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. 直接偏好优化 (Direct Preference Optimization, DPO)&lt;/h1&gt;
&lt;p&gt;以PPO为优化目标产生最优Policy的条件下推出了reward的表达式, 然后将该reward的表达式代入了以Bradley-Terry模型建模的最大似然估计中, 即可得到DPO的Loss. (DPO与PPO的目标是一致的，PPO以强化学习的方式实现了这个目标的优化，DPO认为这个目标有一个解析解，所以把这个解析解推导了出来，最后得到了DPO的loss)&lt;/p&gt;
&lt;p&gt;DPO的核心洞察在于原始强化学习问题存在解析最优解，表明最优策略与奖励函数存在一一映射关系。DPO将此关系反解后代入Bradley-Terry偏好模型，将对奖励函数的似然最大化，等价地转化为直接对策略的似然最大化。因此，优化DPO损失函数即是在直接寻找那个能同时最大化人类偏好概率且满足最优解形式的策略，避免了先用偏好数据拟合奖励模型再进行强化学习过程寻找最优策略.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251109151843.B9pjZc6d.png&amp;amp;w=1302&amp;amp;h=404&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251109151806.DWMzYs_d.png&amp;amp;w=958&amp;amp;h=466&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251115163500.ZIT3TWot.png&amp;amp;w=1536&amp;amp;h=834&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251115163755.pMzCgDWy.png&amp;amp;w=1166&amp;amp;h=978&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251115163852.GRmOifjZ.png&amp;amp;w=1878&amp;amp;h=930&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251115155718.1AKWorPk.png&amp;amp;w=1560&amp;amp;h=246&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>Hot100的ACM模式题解</title><link>https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/hot-100%E7%99%BE%E9%A2%98%E9%A2%98%E8%A7%A3/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/hot-100%E7%99%BE%E9%A2%98%E9%A2%98%E8%A7%A3/</guid><description>把这份模板复制后改成你的 Hot 100 题解文章。</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;两数之和&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;1. 两数之和&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个整数数组 &lt;code&gt;nums&lt;/code&gt; 和一个整数目标值 &lt;code&gt;target&lt;/code&gt;，请你在该数组中找出  &lt;strong&gt;和为目标值&lt;/strong&gt;  &lt;em&gt;&lt;code&gt;target&lt;/code&gt;&lt;/em&gt;  的那  &lt;strong&gt;两个&lt;/strong&gt;  整数，并返回它们的数组下标。&lt;/p&gt;
&lt;p&gt;你可以假设每种输入只会对应一个答案，并且你不能使用两次相同的元素。&lt;/p&gt;
&lt;p&gt;你可以按任意顺序返回答案。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [2,7,11,15], target = 9
输出：[0,1]
解释：因为 nums[0] + nums[1] == 9 ，返回 [0, 1] 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,2,4], target = 6
输出：[1,2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,3], target = 6
输出：[0,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;2 &amp;lt;= nums.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= nums[i] &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= target &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只会存在一个有效答案&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你可以想出一个时间复杂度小于 &lt;code&gt;O(n^2)&lt;/code&gt; 的算法吗？&lt;/p&gt;
&lt;h2&gt;2. 解法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 梦开始的地方
# 如果强硬做需要n方二重循环，首先体现出哈希表空间换时间

# 打一个哈希表值:下标，每一次找哈希表中target-val的数字，如果找到了就返回下标列表，找不到就存入哈希表
def solution(nums,target)-&amp;gt;list:
    dict = {}
    for i,val in enumerate(nums):
        if target-val in dict:
            return [i,dict[target-val]]
        else:
            dict[val] = i
    # 假设都对应答案不用考虑找不到

if __name__ == &quot;__main__&quot;:
    # 我们让输入两行，一行为逗号隔开的数字，另一行target
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    target = int(input().strip())
    print(solution(nums,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;本题是基础哈希表空间换时间，第一次做的时候没有加else，虽然这题无所谓，但是别的题可能会有区别。&lt;/li&gt;
&lt;li&gt;还有一个细节，我用了dict，实际上覆盖了内置的dict，还是用mp比较好&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;成功考虑到了要不要else要不要加，另外，这题还有其他的解法。算是哈希表的基础应用题。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;字母的同分异构词&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;49. 字母异位词分组&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个字符串数组，请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入:&lt;/strong&gt;  strs = [&quot;eat&quot;, &quot;tea&quot;, &quot;tan&quot;, &quot;ate&quot;, &quot;nat&quot;, &quot;bat&quot;]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出:&lt;/strong&gt;  [[&quot;bat&quot;],[&quot;nat&quot;,&quot;tan&quot;],[&quot;ate&quot;,&quot;eat&quot;,&quot;tea&quot;]]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解释：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 strs 中没有字符串可以通过重新排列来形成 &lt;code&gt;&quot;bat&quot;&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;字符串 &lt;code&gt;&quot;nat&quot;&lt;/code&gt; 和 &lt;code&gt;&quot;tan&quot;&lt;/code&gt; 是字母异位词，因为它们可以重新排列以形成彼此。&lt;/li&gt;
&lt;li&gt;字符串 &lt;code&gt;&quot;ate&quot;&lt;/code&gt; ，&lt;code&gt;&quot;eat&quot;&lt;/code&gt; 和 &lt;code&gt;&quot;tea&quot;&lt;/code&gt; 是字母异位词，因为它们可以重新排列以形成彼此。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入:&lt;/strong&gt;  strs = [&quot;&quot;]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出:&lt;/strong&gt;  [[&quot;&quot;]]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 3:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入:&lt;/strong&gt;  strs = [&quot;a&quot;]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出:&lt;/strong&gt;  [[&quot;a&quot;]]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= strs.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= strs[i].length &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;strs[i]&lt;/code&gt; 仅包含小写字母&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 解法 1 · {}&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 白痴做法是排序判断，python快排是onlogn
# 保持on可以用哈希表，这题的意思换句话就是将字母出现个数一样的放在一起
# 维持一个字母表:字符串列表

def solution(strs)-&amp;gt;list[list[str]]:
    mp = {}
    for s in strs:
        ap = [0]*26
        for i in s:
            ap[ord(i)-ord(&apos;a&apos;)] += 1
        # 注意list不能当key
        key = tuple(ap)
        if key not in mp:
            mp[key]=[]
        mp[key].append(s)
    # 现在按照每个ap返回组成的字符串列表
    return list(mp.values())


if __name__ == &quot;__main__&quot;:
    # 输入一串str
    strs = [s.strip().strip(&apos;&quot;&apos;) for s in input().strip().split(&apos;,&apos;)]
    print(solution(strs))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 解法 2 · defaultdict&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections import defaultdict

def solution(strs)-&amp;gt;list[list[str]]:
    # defaultdict可以避免查空建表，遇到没见过的key默认开辟
    # defaultdict(list)就是默认传进来没见过的用list()先构造，也就是list()默认值空列表
    # 同理defaultdict(int)，int()的默认值是0
    # defaultdict(set)还可以叠去重
    groups = defaultdict(list)
    for s in strs:
        count = [0] * 26
        for c in s:
            count[ord(c) - ord(&quot;a&quot;)] += 1
        groups[tuple(count)].append(s)
    return list(groups.values())


if __name__ == &quot;__main__&quot;:
    # 输入一串str
    strs = [s.strip().strip(&apos;&quot;&apos;) for s in input().strip().split(&apos;,&apos;)]
    print(solution(strs))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;实际上做到这里的时候想到用这种方法，但是还是有点小梗塞。容易出错的点：list不能作为key，需要tuple；ord函数别忘了；mp.values()的用法，返回values的迭代器，用list转为答案数组&lt;/li&gt;
&lt;li&gt;如果使用默认数组，不需要先判key空产生[]再append，直接用defaultdict(list)，等于设置了键值的值默认为list的默认值空列表；同理，这里设置为int就是默认值为0&lt;/li&gt;
&lt;li&gt;注意输入处理，因为默认复制力扣的输入是有&quot;的，而想去掉双引号，需要用单引号包裹来strip(&apos;&quot;&apos;)。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;哎呀，二刷错了啊！！竟然直接把哈希表转tuple当key了，哈希表本身tuple之后只能得到key的元组，计算实在想当也需要&lt;code&gt;key = tuple(sorted(Counter(s).items()))&lt;/code&gt;用这种包含完整信息的，但是这样太麻烦了，要么直接用sorted之后当key，要么就是按照原本的做法，打字母表就行了。每个单词的结果作为记数列表当做tuple才是最自然的。不过这次好在想到了空的时候建[]&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;最长连续序列&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;128. 最长连续序列&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个未排序的整数数组 &lt;code&gt;nums&lt;/code&gt; ，找出数字连续的最长序列（不要求序列元素在原数组中连续）的长度。&lt;/p&gt;
&lt;p&gt;请你设计并实现时间复杂度为 &lt;code&gt;O(n)&lt;/code&gt; 的算法解决此问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [100,4,200,1,3,2]
输出：4
解释：最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [0,3,7,2,5,8,4,6,0,1]
输出：9
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,0,1,2]
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= nums[i] &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 解法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 需要on解决问题，只能遍历一遍，必须空间换时间
# 我们先遍历一遍，将数组放进集合，然后找下一个有可能的数字。虽然是循环套循环，但是终归是有限次查找，所以为on

def solution(nums)-&amp;gt;int:
    num_set = set(nums)
    longest = 0
    current_length = 0
    for num in num_set:
        current = num
        current_length = 1
        # 循环找有限个后续
        while current + 1 in num_set:
            current += 1
            current_length += 1 
        longest = max(longest,current_length)
    return longest

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题肯定也是第一时间想到空间换时间，但是没想到哈希存什么。主要是害怕for套for复杂度超过on，但是其实里面是有限次循环，还是on。&lt;/li&gt;
&lt;li&gt;不用set的话问了题友，也是哈希打一遍标记，然后前后找。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;秒了&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;移动零&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;283. 移动零&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个数组 &lt;code&gt;nums&lt;/code&gt;，编写一个函数将所有 &lt;code&gt;0&lt;/code&gt; 移动到数组的末尾，同时保持非零元素的相对顺序。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;请注意&lt;/strong&gt;  ，必须在不复制数组的情况下原地对数组进行操作。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [0]
输出: [0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示&lt;/strong&gt; :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-2^31 &amp;lt;= nums[i] &amp;lt;= 2^31 - 1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;**进阶：**你能尽量减少完成的操作次数吗？&lt;/p&gt;
&lt;h2&gt;2. 解法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 双指针搬运法，很简单
def solution(nums):
    i,j = 0, 0
    n = len(nums)
    if n&amp;lt;=1:
        return nums
    while j&amp;lt;n:
        if nums[j]!=0:
            nums[i]=nums[j]
            i+=1
        j+=1
    # 如果i没走完，则后面全部置零
    while i&amp;lt;n:
        nums[i]=0
        i+=1
    return nums

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;没啥好说的，就是简单的移动插入，跟插入排序有点像，需要注意的是while循环下面别忘了移动变量，这个经常忘记。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;直接i、j都定义为0就不需要特判n&amp;lt;=1了。&lt;/p&gt;
&lt;h2&gt;5. 三刷&lt;/h2&gt;
&lt;p&gt;啊呀才发现一刷有重大问题！！i、j必须从0开始，特判小于等于1也没必要。如果j默认从1开始，就默许了0号位是有效的了，测试点会出错！！&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;盛水最多的容器&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;11. 盛最多水的容器&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个长度为 &lt;code&gt;n&lt;/code&gt; 的整数数组 &lt;code&gt;height&lt;/code&gt; 。有 &lt;code&gt;n&lt;/code&gt; 条垂线，第 &lt;code&gt;i&lt;/code&gt; 条线的两个端点是 &lt;code&gt;(i, 0)&lt;/code&gt; 和 &lt;code&gt;(i, height[i])&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;找出其中的两条线，使得它们与 &lt;code&gt;x&lt;/code&gt; 轴共同构成的容器可以容纳最多的水。&lt;/p&gt;
&lt;p&gt;返回容器可以储存的最大水量。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;说明：&lt;/strong&gt; 你不能倾斜容器。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://aliyun-lc-upload.oss-cn-hangzhou.aliyuncs.com/aliyun-lc-upload/uploads/2018/07/25/question_11.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：[1,8,6,2,5,4,8,3,7]
输出：49 
解释：图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下，容器能够容纳水（表示为蓝色部分）的最大值为 49。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：height = [1,1]
输出：1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n == height.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2 &amp;lt;= n &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= height[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题其实是双指针，一前一后，每次贪心移动比较矮的柱子（因为木桶效应，矮柱子比较碍事）
def solution(heights):
    n = len(heights)
    i, j = 0, n-1
    max_pool = 0
    while i !=j:
        min_height = min(heights[i],heights[j])
        max_pool = max(max_pool,min_height*(j-i))
        if heights[i]&amp;lt;=heights[j]:
            i+=1
        else:
            j-=1
    return max_pool

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题老是幻视成单调栈，但是实际上不是需求“第一个比xx大/小”的量，卡了半天不知道怎么写。&lt;/li&gt;
&lt;li&gt;但是这题实际上是一个贪心，理论依据是“移动高的那边一定不可能得到更优解”，所以只能移动矮的那边去保留希望。这个解释还是让人感觉懵懵的。&lt;/li&gt;
&lt;li&gt;询问码u，最好的解释是“移动小的那根不一定能让水更多，但是大的那根肯定会变少”，因为移动大的那根，小的那根被限制住了，无论如何都不会变大，反而使宽度减小。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;秒，移动短边获得希望。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;三数之和&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;15. 三数之和&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; ，判断是否存在三元组 &lt;code&gt;[nums[i], nums[j], nums[k]]&lt;/code&gt; 满足 &lt;code&gt;i != j&lt;/code&gt;、&lt;code&gt;i != k&lt;/code&gt; 且 &lt;code&gt;j != k&lt;/code&gt; ，同时还满足 &lt;code&gt;nums[i] + nums[j] + nums[k] == 0&lt;/code&gt; 。请你返回所有和为 &lt;code&gt;0&lt;/code&gt; 且不重复的三元组。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意：&lt;/strong&gt; 答案中不可以包含重复的三元组。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [-1,0,1,2,-1,-4]
输出：[[-1,-1,2],[-1,0,1]]
解释：
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意，输出的顺序和三元组的顺序并不重要。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [0,1,1]
输出：[]
解释：唯一可能的三元组和不为 0 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [0,0,0]
输出：[[0,0,0]]
解释：唯一可能的三元组和为 0 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;3 &amp;lt;= nums.length &amp;lt;= 3000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^5 &amp;lt;= nums[i] &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 第一个想法比较直白，二重循环来找第三个数，从而变成两数之和，但是这样就on方了
# 好吧只能on方，想什么呢。既然只有on方，那排序也是可以的了，然后用更优雅的方法：固定一个数+双指针移动
def solution(nums):
    nums.sort()
    ans = []
    for i in range(len(nums)):
        if i &amp;gt; 0 and nums[i] == nums[i - 1]:
            continue
        left, right = i + 1, len(nums) - 1
        while left &amp;lt; right:
            s = nums[i] + nums[left] + nums[right]
            # 以0为分界决定移动哪个指针
            if s &amp;lt; 0:
                left += 1
            elif s &amp;gt; 0:
                right -= 1
            else:
                ans.append([nums[i], nums[left], nums[right]])
                left += 1
                right -= 1
                while left &amp;lt; right and nums[left] == nums[left - 1]:
                    left += 1
                while left &amp;lt; right and nums[right] == nums[right + 1]:
                    right -= 1
    return ans

if __name__==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这一题的去重是大坑。我使用tuple化list，勉强去重成功。但是最好的方法，其实是移动的时候直接忽略相同元素（排序后相同元素在一起）。所以按照题解一样，如果移动后元素还是一样，直接什么也不做跳过去。&lt;/li&gt;
&lt;li&gt;可以剪枝到len(nums)-2&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;接雨水&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;42. 接雨水&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给定 &lt;code&gt;n&lt;/code&gt; 个非负整数表示每个宽度为 &lt;code&gt;1&lt;/code&gt; 的柱子的高度图，计算按此排列的柱子，下雨之后能接多少雨水。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/10/22/rainwatertrap.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出：6
解释：上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图，在这种情况下，可以接 6 个单位的雨水（蓝色部分表示雨水）。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：height = [4,2,0,3,2,5]
输出：9
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n == height.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 2 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= height[i] &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解1 - 单调栈法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 经典单调栈的题目，只要能识别为找到第一个比xxx大/小的题目都可以单调栈
# 本题要找左侧第一个比它高的，还要找右侧第一个比它高的，这样才能形成水洼。左侧最高可以用递减栈记录，右侧最高只能通过出栈的瞬间判断。不过不用担心有的栈不会被弹出，因为找不到右侧比它更高的，就行不成水洼；左侧同理，找left的时候要进行一次保护。
# 坐标语言，(right-left-1)*(左右那个比较矮的和当前的高度差)=累积的水泊
from collections import deque

def solution(height:list):
    # 存储 值:下标
    q = deque()
    pool = 0
    for i,h in enumerate(height):
        while q and q[-1][0]&amp;lt;h:
            curr_val,curr = q.pop()
            right = i
            rigth_val = h
            # 左边第一个比它高的是弹出后的栈顶。如果左边没了，形不成水洼
            if not q:
                break
            left_val,left = q[-1]
            # 加池子
            pool += (min(left_val,rigth_val)-curr_val)*(right-left-1)
        # 进栈
        q.append((h,i))
    return pool

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;实际解题的思路已经写的很清楚了，重要的就是单调栈的思想。&lt;/li&gt;
&lt;li&gt;注意这里不需要全部弹出，右侧可能没有更高的边界；需要注意的是左侧可能会没有水洼，所以要保护一下空栈。&lt;/li&gt;
&lt;li&gt;这里right、right_val就是当前的i、h，更简的话可以不定义right相关变量&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;二刷的时候把问题想简单了，直接把val做单调栈没有带上坐标，然后直接只看高度差来积水。
比较好的思维方式是，看到弹出结算的加上的水，其实是以curr高度托底，左右第一个比较高的柱子之间的差。&lt;/p&gt;
&lt;p&gt;每次比较担心的思维陷阱其实是4、6会不会重复计算水？排除这种情况就可以大胆用长度差*高度差来积水了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fbef7198117f6534804abc366b6817deb_720.ouZvFWBB.png&amp;amp;w=1280&amp;amp;h=547&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;5. 题解2 - 前后缀最大值法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 另一种比较直观的解法，前后缀最大值法。
# 我们在每个位置记忆左侧更高的高度、右侧更高的高度，然后遍历一遍，只拿下这个洼地上方一列的存水量（高度差）。
import ast

def solution(height: list[int]) -&amp;gt; int:
    n = len(height)
    if n == 0:
        return 0

    left_max = [0] * n
    right_max = [0] * n

    left_max[0] = height[0]
    for i in range(1, n):
        left_max[i] = max(left_max[i - 1], height[i])

    right_max[n - 1] = height[n - 1]
    for i in range(n - 2, -1, -1):
        right_max[i] = max(right_max[i + 1], height[i])

    ans = 0
    for i in range(n):
        ans += min(left_max[i], right_max[i]) - height[i]

    return ans


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 题解3 - 双指针法&lt;/h2&gt;
&lt;p&gt;上面的前后缀只要算自己头上的水泊，还是比较清晰的。不过仔细观察就会发现开两个数组没必要，用两个变量就可以了，这样就可以压缩成双变量法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def trap(height):
    left, right = 0, len(height) - 1
    leftMax = rightMax = 0
    res = 0

    while left &amp;lt; right:
        leftMax = max(leftMax, height[left])
        rightMax = max(rightMax, height[right])

        if leftMax &amp;lt; rightMax:
            res += leftMax - height[left]
            left += 1
        else:
            res += rightMax - height[right]
            right -= 1

    return res
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;无重复字符的最长子串&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;3. 无重复字符的最长子串&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个字符串 &lt;code&gt;s&lt;/code&gt; ，请你找出其中不含有重复字符的  &lt;strong&gt;最长 子串&lt;/strong&gt;  的长度。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;abcabcbb&quot;
输出: 3 
解释: 因为无重复字符的最长子串是 &quot;abc&quot;，所以其长度为 3。注意 &quot;bca&quot; 和 &quot;cab&quot; 也是正确答案。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;bbbbb&quot;
输出: 1
解释: 因为无重复字符的最长子串是 &quot;b&quot;，所以其长度为 1。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;pwwkew&quot;
输出: 3
解释: 因为无重复字符的最长子串是 &quot;wke&quot;，所以其长度为 3。
     请注意，你的答案必须是 子串 的长度，&quot;pwke&quot; 是一个子序列，不是子串。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= s.length &amp;lt;= 5 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 由英文字母、数字、符号和空格组成&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 一眼滑动窗口，维持一个哈希表记录window里面的字母有没有重复。每次窗口有效时，记录window长度，更新最大值
def solution(s: str) -&amp;gt; int:
    window = {}
    left = 0
    max_length = 0
    for right, ch in enumerate(s):
        window[ch] = window.get(ch, 0) + 1
        # 这里写只要不合法，就移动左边
        while window[ch] &amp;gt; 1:
            window[s[left]] -= 1
            left += 1
        max_length = max(max_length, right - left + 1)
    return max_length


if __name__ == &quot;__main__&quot;:
    s = input()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;我其实感觉滑动窗口的思路比较简单，但是比较考验码力，特别是边界和条件判断的地方，特别容易绕晕。&lt;/li&gt;
&lt;li&gt;我第一版有很多疏漏，现在是更新过的版本。易错点1：left移动的条件不对，应该写在while里面的是“不合法”的条件，移动left直到合法；易错点2：不能用len(window)，即使哈希值归0了，键值对还在。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;想到用滑动窗口了，但是收缩条件一时想不到，还想着遍历整个哈希表看看大于1有无，其实只要看当前位置就行了。
另外还有一个坑点，我有时候会在left收缩的时候复用c，其实多数情况没事，但是收缩的条件这次是包含c的，就不能直接覆盖了，还是建议以后写成d吧。&lt;/p&gt;
&lt;h2&gt;5. 解法 - 标准滑动窗口法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def solution(s:str) -&amp;gt; int:
    # 维持一个滑动窗口，当重复出现的时候，开始收缩左窗口，直到不重复
    left,right = 0,0
    window = {}
    max_length = 0
    while right&amp;lt;len(s):
        c = s[right]
        right += 1
        window[c] = window.get(c,0)+1
        # 需要收缩的条件
        while window[c]&amp;gt;1:
            d = s[left]
            window[d] -= 1
            left += 1
        max_length = max(max_length,right-left)
    return max_length


if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 三刷&lt;/h2&gt;
&lt;p&gt;这次写对了，但是多加了两个没用的if。if只会被用来判断need，window肯定是加一次删一次，不用if。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;找到字符串中所有字母异位词&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;438. 找到字符串中所有字母异位词&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定两个字符串 &lt;code&gt;s&lt;/code&gt; 和 &lt;code&gt;p&lt;/code&gt;，找到 &lt;code&gt;s&lt;/code&gt; 中所有 &lt;code&gt;p&lt;/code&gt; 的  &lt;strong&gt;异位词&lt;/strong&gt;  的子串，返回这些子串的起始索引。不考虑答案输出的顺序。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;cbaebabacd&quot;, p = &quot;abc&quot;
输出: [0,6]
解释:
起始索引等于 0 的子串是 &quot;cba&quot;, 它是 &quot;abc&quot; 的异位词。
起始索引等于 6 的子串是 &quot;bac&quot;, 它是 &quot;abc&quot; 的异位词。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;abab&quot;, p = &quot;ab&quot;
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 &quot;ab&quot;, 它是 &quot;ab&quot; 的异位词。
起始索引等于 1 的子串是 &quot;ba&quot;, 它是 &quot;ab&quot; 的异位词。
起始索引等于 2 的子串是 &quot;ab&quot;, 它是 &quot;ab&quot; 的异位词。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= s.length, p.length &amp;lt;= 3 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 和 &lt;code&gt;p&lt;/code&gt; 仅包含小写字母&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 依旧滑动窗口
# 换言之，我们需要将p的哈希表和s中滑动窗口的哈希表一样，我们可以用validation记录一样的键时值一样，即合法键值对个数，如果和p的哈希表一样长就对了（为什么不直接==两个哈希表呢？因为哈希表全等是逐元素判断，写上时间就爆了。
def solution(s: str, p: str) -&amp;gt; list[int]:
    need = {}
    window = {}
    # 填充需求哈希表
    for c in p:
        need[c] = need.get(c, 0) + 1
    left = 0
    right = 0
    # 满足条件的元素个数，后面和len(need)比较
    valid = 0
    ans = []

    while right &amp;lt; len(s):
        c = s[right]
        right += 1
        # 右侧扩张，并判断是否增加valid
        if c in need:
            window[c] = window.get(c, 0) + 1
            if window[c] == need[c]:
                valid += 1
        # 我们当有效元素达到要求时，再来看长度，如果满足要求就加上答案，否则left移动
        while valid == len(need):
            if right - left == len(p):
                ans.append(left)

            # 这部分逻辑与right的对称
            d = s[left]
            left += 1

            if d in need:
                if window[d] == need[d]:
                    valid -= 1
                # 无论怎么样移动后都要给这个window最后调整-1
                window[d] -= 1

    return ans

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    p = input().strip()
    print(solution(s,p))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题是非常值得反复练习的滑动窗口题目，我重写的时候又弄错了，将right - left == len(p)作为while条件，这样还要先移动right，非常复杂容易出错。正确的一体化思路，应当是将valid个数合格作为左边收缩的起点，收缩一直进行到valid不满足为止。&lt;/li&gt;
&lt;li&gt;左右的操作实际上是对称的，取值、移动，右侧先动哈希再看valid，左侧先看valid再动哈希。其中的区别在于，left是看先满足再丢弃，right是先拿取再看是否满足。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;哎哟我，二刷的时候又弄错了，混淆了固定窗口的滑动和变长度窗口的滑动。上面的题解是为了和其他窗口valid放在while中对上从而做的调整，ans判读也移动到了收缩内部。但是我们可以写这题的标准滑动窗口，将判断放后面，然后对于固定窗口的题，可以直接把长度作为while的判断。&lt;/p&gt;
&lt;h2&gt;5. 题解2 - 标准固定窗口&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def solution(s: str, p: str) -&amp;gt; list[int]:
    ans = []
    need = {}
    window = {}
    left, right = 0, 0
    valid = 0

    for c in p:
        need[c] = need.get(c, 0) + 1

    while right &amp;lt; len(s):
        c = s[right]
        right += 1
        # 只统计需要的
        if c in need:
            window[c] = window.get(c, 0) + 1
            if window[c] == need[c]:
                valid += 1

        # 固定窗口长度，超过 len(p) 就收缩
        while right - left &amp;gt; len(p):
            d = s[left]
            left += 1

            if d in need:
                if window[d] == need[d]:
                    valid -= 1
                window[d] -= 1

        # 长度刚好且所有字符频次都匹配，记录答案
        if right - left == len(p) and valid == len(need):
            ans.append(left)

    return ans


if __name__ == &quot;__main__&quot;:
    s = input().strip()
    p = input().strip()
    print(solution(s, p))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;和为k的子数组&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;560. 和为 K 的子数组&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; 和一个整数 &lt;code&gt;k&lt;/code&gt; ，请你统计并返回 &lt;em&gt;该数组中和为 &lt;code&gt;k&lt;/code&gt; 的子数组的个数&lt;/em&gt; 。&lt;/p&gt;
&lt;p&gt;子数组是数组中元素的连续非空序列。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,1,1], k = 2
输出：2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,2,3], k = 3
输出：2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 2 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-1000 &amp;lt;= nums[i] &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^7 &amp;lt;= k &amp;lt;= 10^7&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 前缀和+哈希表，和两数之和、路径总和III是同一个模板
def solution(nums,k):
    prefix = {0: 1}
    cur = ans = 0
    for v in nums:
        cur += v
        ans += prefix.get(cur - k, 0)
        prefix[cur] = prefix.get(cur, 0) + 1
    return ans

if __name__ == “__main__”:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input())
    print(solution(nums,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;本题一开始用滑动窗口写，但&lt;strong&gt;滑动窗口要求窗口扩大时 total 单调递增&lt;/strong&gt;，即所有元素必须 &amp;gt;= 0。本题 nums[i] 取值范围 &lt;code&gt;[-1000, 1000]&lt;/code&gt;，包含负数，&lt;code&gt;while total &amp;gt; k&lt;/code&gt; 缩窗口的逻辑不成立——踢掉一个负数 total 反而变大，可能漏掉合法子数组。反例：&lt;code&gt;nums=[-1,-1,1], k=0&lt;/code&gt;，正确答案是 1（整个数组），滑动窗口会输出 0。&lt;/li&gt;
&lt;li&gt;正确做法是前缀和+哈希表：遍历时维护 &lt;code&gt;cur&lt;/code&gt;（前缀和），对于每个位置，查哈希表中 &lt;code&gt;cur - k&lt;/code&gt; 出现的次数。本质上和两数之和是同一个套路——&lt;code&gt;cur - k = 之前某个前缀和&lt;/code&gt;，那中间那段的和就是 k。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;知道为什么不能用滑动窗口，然后能写出前缀和+哈希表。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;滑动窗口最大值&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;239. 滑动窗口最大值&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt;，有一个大小为 &lt;code&gt;k&lt;/code&gt; 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 &lt;code&gt;k&lt;/code&gt; 个数字。滑动窗口每次只向右移动一位。&lt;/p&gt;
&lt;p&gt;返回 &lt;em&gt;滑动窗口中的最大值&lt;/em&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,3,-1,-3,5,3,6,7], k = 3
输出：[3,3,5,5,6,7]
解释：
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1], k = 1
输出：[1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= k &amp;lt;= nums.length&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 解法 1 · 单调队列&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 本地要求的是连续子数组（还固定长度）的与顺序无关的属性（最大值），但是与哈希表、总和不同的是，max不能回退。
# 实际上这一题做法是单调递减队列。单调队列的思想在于维持一个“有效期”，因为max不能回退，就要存储过去合法的max，队首是目前的max
from collections import deque

def solution(nums,k):
    q = deque()
    ans = []
    # 这里我们选择存下标，一般比存值更稳定，可以避免重复值
    for i,x in enumerate(nums):
        # 构造递减队列
        while q and nums[q[-1]]&amp;lt;=x:
            q.pop()
        q.append(i)
        # 检查左边有效期，下标已经不在窗口内，踢出队首
        if q[0]&amp;lt;= i-k:
            q.popleft()
        # 如果长度达到k了，就记录答案。由于前面if的约束，必定会-1，然后+1，所以这里一定是要求的长度
        if i&amp;gt;= k-1:
            ans.append(nums[q[0]])
    return ans

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input())
    print(solution(nums,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 解法 2 · 堆&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 我们依旧需要维持有序性，单调队列确实是最好的方法，但是思路有点绕
# 一个比较直白的方法，就是做一个最大堆，堆里存值和下标，堆顶下标超过了窗口范围，就弹出，然后下标达到k之后，不断进入、弹出……
import heapq

def solution(nums,k):
    ans,pq = [],[]
    for i,val in enumerate(nums):
        heapq.heappush_max(pq,(val,i))
        while pq and pq[0][1]&amp;lt;= i-k:
            heapq.heappop_max(pq)
        if i&amp;gt;=k-1:
            ans.append(pq[0][0])
    return ans

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input())
    print(solution(nums,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题一开始想到的还是滑动窗口，但是不知道怎么维护max的回退，一开始想到用栈，但是栈没办法处理退出的就是最大值，第二个大值在哪这个问题。&lt;/li&gt;
&lt;li&gt;这题的破局关键其中之一就是存储下标。对于可能有重复值的题目，存下标是最安全稳妥的方法。&lt;/li&gt;
&lt;li&gt;这题的标准思路是单调队列，单调队列常被用来解决”有效性窗口“的问题，比如这题，就是维持了一系列候选有效的max值，left移动就看左边的第一个值有没有过期（也就是看最大值有没有过期）&lt;/li&gt;
&lt;li&gt;同样可以用堆来解决，两者思路差不多，都是存储候选，left移动看看候选最大值有没有过期，过期让老二上。（但是复杂度跌到onlogn了）&lt;/li&gt;
&lt;li&gt;特别注意，这里无论是队列长度还是堆长度，都跟窗口的长度无关。所以我们判断窗口是否合法，还是要用下标来判断。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;被秒了。。直接用堆，但是收缩时只看堆顶不对，这个写法只会在“离开的元素刚好等于当前最大值”时弹一下，否则堆里会残留很多已经不在窗口里的旧元素。必须要加上过期机制，比如加入坐标一起入堆，然后看坐标判断过期，或者干脆跟题解一样用单调队列。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;最小覆盖子串&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;76. 最小覆盖子串&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给定两个字符串 &lt;code&gt;s&lt;/code&gt; 和 &lt;code&gt;t&lt;/code&gt;，长度分别是 &lt;code&gt;m&lt;/code&gt; 和 &lt;code&gt;n&lt;/code&gt;，返回 s 中的  &lt;strong&gt;最短窗口 子串&lt;/strong&gt; ，使得该子串包含 &lt;code&gt;t&lt;/code&gt; 中的每一个字符（ &lt;strong&gt;包括重复字符&lt;/strong&gt; ）。如果没有这样的子串，返回空字符串 &lt;code&gt;&quot;&quot;&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;测试用例保证答案唯一。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;ADOBECODEBANC&quot;, t = &quot;ABC&quot;
输出：&quot;BANC&quot;
解释：最小覆盖子串 &quot;BANC&quot; 包含来自字符串 t 的 &apos;A&apos;、&apos;B&apos; 和 &apos;C&apos;。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;a&quot;, t = &quot;a&quot;
输出：&quot;a&quot;
解释：整个字符串 s 是最小覆盖子串。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;a&quot;, t = &quot;aa&quot;
输出: &quot;&quot;
解释: t 中两个字符 &apos;a&apos; 均应包含在 s 的子串中，
因此没有符合条件的子字符串，返回空字符串。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == s.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == t.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 和 &lt;code&gt;t&lt;/code&gt; 由英文字母组成&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你能设计一个在 &lt;code&gt;O(m + n)&lt;/code&gt; 时间内解决此问题的算法吗？&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 一眼滑动窗口，用t建立need，t的长度就是validation需要达到的量。validation达标的情况下left移动
def solution(s: str, t: str) -&amp;gt; str:
    need = {}
    window = {}
    # 记录need
    for c in t:
        need[c] = need.get(c, 0) + 1
    left = 0
    right = 0
    valid = 0
    start = 0
    min_len = float(&quot;inf&quot;)

    while right &amp;lt; len(s):
        c = s[right]
        right += 1
        # 如果在need中，再记录（不在need中的也不可能对valid产生影响，可以不管）
        if c in need:
            window[c] = window.get(c, 0) + 1
            if window[c] == need[c]:
                valid += 1
        # 当valid满足了，left收缩
        while valid == len(need):
            if right - left &amp;lt; min_len:
                # 同时记录最短长度和起点
                start = left
                min_len = right - left
            # 执行对称收缩即可
            d = s[left]
            left += 1
            if d in need:
                if window[d] == need[d]:
                    valid -= 1
                window[d] -= 1
    # 返回值进行一下inf保护
    return &quot;&quot; if min_len == float(&quot;inf&quot;) else s[start:start + min_len]


if __name__ == &quot;__main__&quot;:
    s = input().strip()
    t = input().strip()
    m = len(s)
    n = len(t)
    print(solution(s,t,m,n))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;滑动窗口果然容易码错。虽然对称的left、right操作已经记熟了，但是仍然忘记先看need再哈希。&lt;/li&gt;
&lt;li&gt;满足条件再收缩，收缩前就已经满足的，在收缩代码里拿答案；收缩后才合法的，在收缩完成后拿答案。两种情况取决于写的while，一定要注意分辨。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;写错了。这次虽然写对了在need中才加入哈希的逻辑，但是收缩还是写错了。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;收缩时不能一见到 d in need 就直接 valid -= 1，还要进一步看if window[d] == need[d]。因为valid只记录是否达到过合法，即使window再进元素也只会加哈希表，反之减少元素也不一定会损失合法性，可以脑内模拟&lt;/li&gt;
&lt;li&gt;注意valid等于的时候判断位置，应该在收缩刚开始。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 三刷&lt;/h2&gt;
&lt;p&gt;我草，三刷暴露出了重要问题，观察以下两种写法&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if c2 in need:
    window[c2] -= 1
    if window[c2] &amp;lt; need[c2]:
        valid -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;和&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if c2 in need:
    if window[c2] == need[c2]:
        valid -= 1
    window[c2] -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;乍一看两者没什么区别，但是这里由于只要小于window数量小于need就会削减，只要数量不够，每次移出字符都在疯狂扣分，导致 valid 错乱！&lt;/p&gt;
&lt;p&gt;虽然我们肯定是要移动的，但是我们最好在移动之前看看是不是正好有效，如果是的才减少valid，这样就不会滥减valid。顺带一提，第一种情况leetcode反例&quot;dinitrophenylhydrazinetrinitrophenylmethylnitramine&quot;和&quot;trinitrophenylmethylnitramine&quot;。&lt;/p&gt;
&lt;p&gt;这是固定窗口用滑动窗口解的bug，上一题直接用范围判断其实都没事，因为我们收缩的时候会保证valid达标，所以不可能会出现无辜收缩乱扣valid的情况。不过为了统一，记住以后先判断 window[c2] == need[c2]，决定是否要 valid -= 1，再 window[c2] -= 1&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;最大子数组和&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;53. 最大子数组和&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; ，请你找出一个具有最大和的连续子数组（子数组最少包含一个元素），返回其最大和。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;子数组&lt;/strong&gt;  是数组中的一个连续部分。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [-2,1,-3,4,-1,2,1,-5,4]
输出：6
解释：连续子数组 [4,-1,2,1] 的和最大，为 6 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1]
输出：1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [5,4,-1,7,8]
输出：23
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 如果你已经实现复杂度为 &lt;code&gt;O(n)&lt;/code&gt; 的解法，尝试使用更为精妙的  &lt;strong&gt;分治法&lt;/strong&gt;  求解。&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 数组里面有负数，来新元素进来不一定增大，丢弃也不一定变小
# 连续子数组，顺序无关要素（最大和），比较符合滑动窗口。
# 但这题其实的关键其实在数是不是负数。当前位置结束的最大子数组只有两种选择：重新开始只取x，或者接在前面子数组后面，变成pre+x。这其实就是动态规划的方法
# 这种求最大子数组和的问题，不用打表，可以称为Kadane 算法
def solution(nums)-&amp;gt;int:
    cur = nums[0]
    ans = nums[0]

    for i in range(1,len(nums)):
        # 先加上这一个位置，看看是否会增大
        # 如果加上更小，不如另开（选nums[i]）
        cur = max(nums[i],cur+nums[i])
        # 维持一个最大值
        ans = max(ans,cur)
    return ans

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题被叫做Kadane算法，他其实是一种动态规划的思想的优化简化，用于处理另起炉灶还是继续加入的选择哪个更好，然后维持一个全局的最优ans&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;二刷用了标准dp写出来，不过也是想了一段时间。&lt;/p&gt;
&lt;h2&gt;5. 题解2 - 标准dp&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(nums:list) -&amp;gt; int:
    # 我们用dp[i]表示到以第i位结尾的最大和连续字数组
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    for i in range(1,n):
        dp[i] = max(nums[i],dp[i-1]+nums[i])
    return max(dp)


 
if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;合并区间&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;56. 合并区间&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;以数组 &lt;code&gt;intervals&lt;/code&gt; 表示若干个区间的集合，其中单个区间为 &lt;code&gt;intervals[i] = [starti, endi]&lt;/code&gt; 。请你合并所有重叠的区间，并返回 &lt;em&gt;一个不重叠的区间数组，该数组需恰好覆盖输入中的所有区间&lt;/em&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：intervals = [[1,3],[2,6],[8,10],[15,18]]
输出：[[1,6],[8,10],[15,18]]
解释：区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：intervals = [[1,4],[4,5]]
输出：[[1,5]]
解释：区间 [1,4] 和 [4,5] 可被视为重叠区间。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：intervals = [[4,7],[1,4]]
输出：[[1,7]]
解释：区间 [1,4] 和 [4,7] 可被视为重叠区间。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= intervals.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;intervals[i].length == 2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= starti &amp;lt;= endi &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这一题两个区间重合的条件是，new_left&amp;lt;=old_right且new_right&amp;gt;=old_left。我们可以用这个条件判断重合，如果重合，就更新左侧为min的，右侧为max的
# 用一个栈来存储，方便操作
# 但是要注意的是，要先按照左端点排序。这是最大坑点，可能传入的是以前的也能合并。左端点排完序之后new_right&amp;gt;=old_left就不需要了

from collections import deque

def solution(intervals:list[list[int]])-&amp;gt;list[list[int]]:
    intervals.sort()
    ans = []
    for left,right in intervals:
        # 如果ans为空或者不用合并
        if not ans or left&amp;gt;ans[-1][1]:
            ans.append([left,right])
        else:
            # 否则需要合并，更新为比较大的右端点
            ans[-1][1] = max(ans[-1][1],right)
    return ans

if __name__ == &quot;__main__&quot;:
    # 注意这里的输入，我们让每行输入两个，不定行
    intervals = []
    while True:
        try:
            interval = list(map(int,input().strip().split(&apos;,&apos;)))
            intervals.append(interval)
        except EOFError:
            break
    print(solution(intervals))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题重合需要注意可能传进去之前的，传入的左端点还不一定按时间排序，需要自己排序一下。&lt;/li&gt;
&lt;li&gt;排序算法intervals.sort(key = lambda x:x[0])可以简写成intervals.sort()。因为python的sort可以默认按照第一项排序。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;只有按左端点排序之后，才能on排序。这题标准解就是sort之后只看右断点，不用多想。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;轮转数组&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;189. 轮转数组&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个整数数组 &lt;code&gt;nums&lt;/code&gt;，将数组中的元素向右轮转 &lt;code&gt;k&lt;/code&gt; 个位置，其中 &lt;code&gt;k&lt;/code&gt; 是非负数。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
解释:
向右轮转 1 步: [7,1,2,3,4,5,6]
向右轮转 2 步: [6,7,1,2,3,4,5]
向右轮转 3 步: [5,6,7,1,2,3,4]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [-1,-100,3,99], k = 2
输出：[3,99,-1,-100]
解释: 
向右轮转 1 步: [99,-1,-100,3]
向右轮转 2 步: [3,99,-1,-100]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-2^31 &amp;lt;= nums[i] &amp;lt;= 2^31 - 1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= k &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;尽可能想出更多的解决方案，至少有  &lt;strong&gt;三种&lt;/strong&gt;  不同的方法可以解决这个问题。&lt;/li&gt;
&lt;li&gt;你可以使用空间复杂度为 &lt;code&gt;O(1)&lt;/code&gt; 的  &lt;strong&gt;原地&lt;/strong&gt;  算法解决这个问题吗？&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 1 · 切片&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题直接考查python切片操作就行了
if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input().strip())

    k = k%(len(nums))
    print(nums[-k:]+nums[:-k])
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 三次翻转&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 题目进阶要求是O(1)空间，那么我们需要做的其实是三次翻转
def reverse(left, right):
    while left &amp;lt; right:
        nums[left], nums[right] = nums[right], nums[left]
        left += 1
        right -= 1

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input().strip())
    
    n = len(nums)
    k = k % n
    reverse(0, n - 1)
    reverse(0, k - 1)
    reverse(k, n - 1)
    print(nums)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 题解 3 · 环状替换&lt;/h2&gt;
&lt;p&gt;每个元素直接跳到它最终该去的位置，一条链跳到底。但是当n与k不互质的情况下，一轮走不完，所以外层必须要从start=0,1,...,gcd(n,k)-1 各启动一轮。(当然，我们也不用非要算这个gcd，直接维持一个全局的count，只要所有数都交换过了，就停止)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 环装替换的思路，是每个元素都会最终被放到(i+k) % n的位置，这样会形成若干个首尾相连的环，我们一直替换指导回到起点
def rotate(nums: list[int], k: int) -&amp;gt; None:
    n = len(nums)
    k %= n
    count = 0
    start = 0

    while count &amp;lt; n:
        current = start
        prev = nums[start]

        while True:
            nxt = (current + k) % n
            nums[nxt], prev = prev, nums[nxt]
            current = nxt
            count += 1

            if current == start:
                break

        start += 1

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input().strip())
    
    rotate(nums,k)
    print(nums)

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;如果不限制空间，这题cpp也能直接建立队列做。python切片的复杂度是O(k)，会创建新列表&lt;/li&gt;
&lt;li&gt;但是本题想考察的重点其实是你能不能写出O(1)的算法，也就是真的原地。&lt;/li&gt;
&lt;li&gt;三次翻转是比较常规的方法，后面链表题也是这么写的。&lt;/li&gt;
&lt;li&gt;环状替换比较绕，比较硬核，到时候有空再看看吧。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;6. 二刷&lt;/h2&gt;
&lt;p&gt;别忘了python不支持自定义起点终点的反转。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;除自身以外数组的乘积&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;238. 除了自身以外数组的乘积&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt;，返回 数组 &lt;code&gt;answer&lt;/code&gt; ，其中 &lt;code&gt;answer[i]&lt;/code&gt; 等于 &lt;code&gt;nums&lt;/code&gt; 中除了 &lt;code&gt;nums[i]&lt;/code&gt; 之外其余各元素的乘积 。&lt;/p&gt;
&lt;p&gt;题目数据  &lt;strong&gt;保证&lt;/strong&gt;  数组 &lt;code&gt;nums&lt;/code&gt;之中任意元素的全部前缀元素和后缀的乘积都在   &lt;strong&gt;32 位&lt;/strong&gt;  整数范围内。&lt;/p&gt;
&lt;p&gt;请  &lt;strong&gt;不要使用除法，&lt;/strong&gt; 且在 &lt;code&gt;O(n)&lt;/code&gt; 时间复杂度内完成此题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [1,2,3,4]
输出: [24,12,8,6]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [-1,1,0,-3,3]
输出: [0,0,9,0,0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;2 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-30 &amp;lt;= nums[i] &amp;lt;= 30&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;输入  &lt;strong&gt;保证&lt;/strong&gt;  数组 &lt;code&gt;answer[i]&lt;/code&gt; 在   &lt;strong&gt;32 位&lt;/strong&gt;  整数范围内&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你可以在 &lt;code&gt;O(1)&lt;/code&gt; 的额外空间复杂度内完成这个题目吗？（ 出于对空间复杂度分析的目的，输出数组  &lt;strong&gt;不被视为&lt;/strong&gt;  额外空间。）&lt;/p&gt;
&lt;h2&gt;2. 题解 1 · 前缀后缀积O(n)&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 经典前缀积问题，不允许使用除法的话，那么就前缀积+后缀积就行了
def solution(nums):
    n = len(nums)
    prefix = [1]*(n+1)
    suffix = [1]*(n+1)
    ans = [0]*n
    # 构造前缀积
    for i in range(1,n+1):
        prefix[i] = nums[i-1]*prefix[i-1]
    # 构造后缀积
    for i in range(n-1,-1,-1):
        suffix[i] = nums[i]*suffix[i+1]
    # 我们构造答案，每个位置其实是i-1的前缀积乘以i+1的后缀积
    for i in range(n):
        ans[i] = prefix[i]*suffix[i+1]
    return ans

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 空间O(1)&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 在前缀后缀的题目中，有时候并不需要使用这个数组本身，可以一遍做一边存答案，只用一个变量解决
# 本题可以用ans存前缀积，然后suffix直接算。不算答案数组可以O(1)的额外空间
def solution(nums):
    n = len(nums)
    ans = [1] * n

    # ans[i] 先保存左边乘积
    for i in range(1, n):
        ans[i] = ans[i - 1] * nums[i - 1]

    # suffix 保存右边乘积
    suffix = 1
    for i in range(n - 1, -1, -1):
        ans[i] *= suffix
        suffix *= nums[i]

    return ans


if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;经典前缀后缀题，需要注意的是不常用的后缀怎么设置数组（边界问题）&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;缺失的第一个正数&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;41. 缺失的第一个正数&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给你一个未排序的整数数组 &lt;code&gt;nums&lt;/code&gt; ，请你找出其中没有出现的最小的正整数。&lt;/p&gt;
&lt;p&gt;请你实现时间复杂度为 &lt;code&gt;O(n)&lt;/code&gt; 并且只使用常数级别额外空间的解决方案。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,2,0]
输出：3
解释：范围 [1,2] 中的数字都在数组中。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,4,-1,1]
输出：2
解释：1 在数组中，但 2 没有。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [7,8,9,11,12]
输出：1
解释：最小的正数 1 没有出现。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-2^31 &amp;lt;= nums[i] &amp;lt;= 2^31 - 1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 循环一遍建立集合，然后用最小正数往下跳就行了。但是要求常数级别的额外空间，这法子不行了。
# 这题的关键在于，“直接用下标当哈希表”。因为正整数一定是从1开始到n+1。我们只要不断交换，让数回到自己所在位置，即num应该去num-1的位置。然后再扫一遍，看每个位置对不对
def solution(nums) -&amp;gt; int:
    n = len(nums)

    for i in range(n):
        while 1 &amp;lt;= nums[i] &amp;lt;= n and nums[nums[i] - 1] != nums[i]:
            j = nums[i] - 1
            nums[i], nums[j] = nums[j], nums[i]

    for i in range(n):
        if nums[i] != i + 1:
            return i + 1

    return n + 1

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题不给你用额外的空间，但是要想到下标和数值本身是有关系的，直接用下标来对应就行了。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;想到了用下标存对应数，但是要记住判读一下值的范围（可能超出下标了）。另外原理是，不管里面存的什么数，第一个不在范围内的正数一定在最大下标+1范围内 。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;矩阵置零&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;73. 矩阵置零&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个 &lt;code&gt;_m_ x _n_&lt;/code&gt; 的矩阵，如果一个元素为  &lt;strong&gt;0&lt;/strong&gt;  ，则将其所在行和列的所有元素都设为  &lt;strong&gt;0&lt;/strong&gt;  。请使用  &lt;strong&gt;&lt;a href=&quot;http://baike.baidu.com/item/%E5%8E%9F%E5%9C%B0%E7%AE%97%E6%B3%95&quot;&gt;原地&lt;/a&gt;&lt;/strong&gt;  算法 &lt;strong&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/08/17/mat1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,1,1],[1,0,1],[1,1,1]]
输出：[[1,0,1],[0,0,0],[1,0,1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/08/17/mat2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]
输出：[[0,0,0,0],[0,4,5,0],[0,3,1,0]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == matrix.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == matrix[0].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 200&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-2^31 &amp;lt;= matrix[i][j] &amp;lt;= 2^31 - 1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个直观的解决方案是使用  &lt;code&gt;O(_m__n_)&lt;/code&gt; 的额外空间，但这并不是一个好的解决方案。&lt;/li&gt;
&lt;li&gt;一个简单的改进方案是使用 &lt;code&gt;O(_m_ + _n_)&lt;/code&gt; 的额外空间，但这仍然不是最好的解决方案。&lt;/li&gt;
&lt;li&gt;你能想出一个仅使用常量空间的解决方案吗？&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 我的想法是先遍历一遍，把0的行、列分别放进两个set中
# 然后再二重循环，如果i in set_i or j in set_j，就直接变0。但是这种方法不满足进阶要求，额外空间复杂度是O(m+n)。不过先把这个做出来吧
def solution(matrix:list[list[int]])-&amp;gt;list:
    m = len(matrix)
    n = len(matrix[0])
    set_i = set()
    set_j = set()
    for i in range(m):
        for j in range(n):
            if matrix[i][j]==0:
                set_i.add(i)
                set_j.add(j)
    
    for i in range(m):
        for j in range(n):
            if i in set_i or j in set_j:
                matrix[i][j] = 0

if __name__ == &quot;__main__&quot;:
    matrix = []
    while True:
        try:
            line = list(map(int,input().strip().split(&apos;,&apos;)))
            matrix.append(line)
        except EOFError:
            break
    solution(matrix)
    print(matrix)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 · 常数空间&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 现在要实现常数空间做法
# 具体的做法是，拿矩阵的第一行和第一列当标记位。依旧是不用空间的话，就要利用用自身的下标。
def solution(matrix) -&amp;gt; None:
    m = len(matrix)
    n = len(matrix[0])

    first_row_zero = False
    first_col_zero = False

    # 判断第一行是否原本有 0
    for j in range(n):
        if matrix[0][j] == 0:
            first_row_zero = True
            break

    # 判断第一列是否原本有 0
    for i in range(m):
        if matrix[i][0] == 0:
            first_col_zero = True
            break

    # 用第一行和第一列做标记
    for i in range(1, m):
        for j in range(1, n):
            if matrix[i][j] == 0:
                matrix[i][0] = 0
                matrix[0][j] = 0

    # 根据标记置零
    for i in range(1, m):
        for j in range(1, n):
            if matrix[i][0] == 0 or matrix[0][j] == 0:
                matrix[i][j] = 0

    # 最后处理第一行
    if first_row_zero:
        for j in range(n):
            matrix[0][j] = 0

    # 最后处理第一列
    if first_col_zero:
        for i in range(m):
            matrix[i][0] = 0


if __name__ == &quot;__main__&quot;:
    matrix = []
    while True:
        try:
            line = list(map(int,input().strip().split(&apos;,&apos;)))
            matrix.append(line)
        except EOFError:
            break
    solution(matrix)
    print(matrix)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;也是限制空间，这时候一定要活用原本的结构。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;秒了，但是要注意标记、对标记的时候，都不要用第一行或者第一列了，不然有一个0就直接先给第一行标满，然后全0了&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;螺旋矩阵&lt;/h1&gt;
&lt;h4&gt;54. 螺旋矩阵&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个 &lt;code&gt;m&lt;/code&gt; 行 &lt;code&gt;n&lt;/code&gt; 列的矩阵 &lt;code&gt;matrix&lt;/code&gt; ，请按照  &lt;strong&gt;顺时针螺旋顺序&lt;/strong&gt;  ，返回矩阵中的所有元素。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/11/13/spiral1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出：[1,2,3,6,9,8,7,4,5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/11/13/spiral.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]
输出：[1,2,3,4,8,12,11,10,9,5,6,7]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == matrix.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == matrix[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= matrix[i][j] &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 螺旋路径使用经典四边界挤压
def solution(matrix):
    if not matrix or not matrix[0]:
        return []
    ans = []
    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1
    # 这题最明晰的做法，就是一个大循环套四个小循环
    while top &amp;lt;= bottom and left &amp;lt;= right:
        # 左到右
        for j in range(left, right + 1):
            ans.append(matrix[top][j])
        top += 1
        # 上到下
        for i in range(top, bottom + 1):
            ans.append(matrix[i][right])
        right -= 1
        # 右到左，这里要判断一下top和bottom的关系，然后才能回过头走
        if top &amp;lt;= bottom:
            for j in range(right, left - 1, -1):
                ans.append(matrix[bottom][j])
            bottom -= 1
        # 下到上，判断left和right的关系
        if left &amp;lt;= right:
            for i in range(bottom, top - 1, -1):
                ans.append(matrix[i][left])
            left += 1
    return ans

if __name__ == &quot;__main__&quot;:
    matrix = []
    while True:
        try:
            line = list(map(int,input().strip().split(&apos;,&apos;)))
            matrix.append(line)
        except EOFError:
            break
    print(solution(matrix))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 二刷&lt;/h2&gt;
&lt;p&gt;二刷用了变动即判断的思路，写的可能代码更多，但是思路更加清晰&lt;/p&gt;
&lt;h2&gt;3. 题解2 - 变动即判定&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(matrix:list[list[int]]) -&amp;gt; list[int]:
    if not matrix or not matrix[0]:
        return []
    # 四边界法
    m = len(matrix)
    n = len(matrix[0])
    top,left = 0, 0 
    bottom, right = m-1, n-1
    ans = []
    while True:
        for j in range(left,right+1):
            ans.append(matrix[top][j])
        top += 1
        if top &amp;gt; bottom:
            break

        for i in range(top,bottom+1):
            ans.append(matrix[i][right])
        right -= 1
        if left &amp;gt; right:
            break

        for j in range(right,left-1,-1):
            ans.append(matrix[bottom][j])
        bottom -= 1
        if top &amp;gt; bottom:
            break

        for i in range(bottom,top-1,-1):
            ans.append(matrix[i][left])
        left += 1
        if left &amp;gt; right:
            break
    return ans
    

 
if __name__ == &quot;__main__&quot;:
    matrix = ast.literal_eval(input().strip())
    print(solution(matrix))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;旋转图像&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;48. 旋转图像&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个 &lt;em&gt;n&lt;/em&gt; × &lt;em&gt;n&lt;/em&gt; 的二维矩阵 &lt;code&gt;matrix&lt;/code&gt; 表示一个图像。请你将图像顺时针旋转 90 度。&lt;/p&gt;
&lt;p&gt;你必须在  &lt;strong&gt;&lt;a href=&quot;https://baike.baidu.com/item/%E5%8E%9F%E5%9C%B0%E7%AE%97%E6%B3%95&quot;&gt;原地&lt;/a&gt;&lt;/strong&gt;  旋转图像，这意味着你需要直接修改输入的二维矩阵。 &lt;strong&gt;请不要&lt;/strong&gt;  使用另一个矩阵来旋转图像。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/08/28/mat1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出：[[7,4,1],[8,5,2],[9,6,3]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/08/28/mat2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
输出：[[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n == matrix.length == matrix[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 20&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-1000 &amp;lt;= matrix[i][j] &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这一题的结论是，先沿主对角线翻转，然后再研轴翻转
def solution(matrix:list[list[int]])-&amp;gt;None:
    # 先沿对角线翻转
    for i in range(len(matrix)):
        for j in range(i+1,len(matrix[0])):
            matrix[i][j],matrix[j][i] = matrix[j][i],matrix[i][j]
    # 再沿中轴翻转
    for line in matrix:
        line.reverse()

if __name__ == &quot;__main__&quot;:
    matrix = []
    while True:
        try:
            line = list(map(int,input().strip().split(&apos;,&apos;)))
            matrix.append(line)
        except EOFError:
            break
    solution(matrix)
    print(matrix)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;秒了，这玩意也就考一遍套路&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;搜索二维矩阵 II&lt;/h1&gt;
&lt;h4&gt;240. 搜索二维矩阵 II&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;编写一个高效的算法来搜索 &lt;code&gt;_m_ x _n_&lt;/code&gt; 矩阵 &lt;code&gt;matrix&lt;/code&gt; 中的一个目标值 &lt;code&gt;target&lt;/code&gt; 。该矩阵具有以下特性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每行的元素从左到右升序排列。&lt;/li&gt;
&lt;li&gt;每列的元素从上到下升序排列。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2020/11/25/searchgrid2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 5
输出：true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2020/11/25/searchgrid.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 20
输出：false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == matrix.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == matrix[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n, m &amp;lt;= 300&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= matrix[i][j] &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;每行的所有元素从左到右升序排列&lt;/li&gt;
&lt;li&gt;每列的所有元素从上到下升序排列&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= target &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 基本做法是从右上角开始搜，如果大于target，肯定不在这一列，左移；如果小于target，直接往下搜索。
def solution(matrix, target) -&amp;gt; bool:
    m = len(matrix)
    n = len(matrix[0])
    i = 0
    j = n - 1

    while i &amp;lt; m and j &amp;gt;= 0:
        if matrix[i][j] == target:
            return True
        elif matrix[i][j] &amp;gt; target:
            j -= 1
        else:
            i += 1

    return False


if __name__ == &quot;__main__&quot;:
    lines= []
    while True:
        try:
            # 一次性读入
            lines.append(input().strip())
        except EOFError:
            break
    target = int(lines[-1])
    # 注意这里的提取前面行数的方法
    matrix = [list(map(int, line.split(&apos;,&apos;))) for line in lines[:-1]]
    print(solution(matrix,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;其实建议直接记住，另外注意输入的时候最后target的处理方式，即我们先拿到所有数据，然后再提取需要的量（用切片）&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;右上角的要用i、j和m、n分开，思路倒是见一次就会了&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;相交链表&lt;/h1&gt;
&lt;h4&gt;160. 相交链表&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你两个单链表的头节点 &lt;code&gt;headA&lt;/code&gt; 和 &lt;code&gt;headB&lt;/code&gt; ，请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点，返回 &lt;code&gt;null&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;图示两个链表在节点 &lt;code&gt;c1&lt;/code&gt; 开始相交 &lt;strong&gt;：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/14/160_statement.png&quot;&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/14/160_statement.png&quot; alt=&quot;&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;题目数据  &lt;strong&gt;保证&lt;/strong&gt;  整个链式结构中不存在环。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意&lt;/strong&gt; ，函数返回结果后，链表必须  &lt;strong&gt;保持其原始结构&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;自定义评测：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;评测系统&lt;/strong&gt;  的输入如下（你设计的程序  &lt;strong&gt;不适用&lt;/strong&gt;  此输入）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;intersectVal&lt;/code&gt; - 相交的起始节点的值。如果不存在相交节点，这一值为 &lt;code&gt;0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;listA&lt;/code&gt; - 第一个链表&lt;/li&gt;
&lt;li&gt;&lt;code&gt;listB&lt;/code&gt; - 第二个链表&lt;/li&gt;
&lt;li&gt;&lt;code&gt;skipA&lt;/code&gt; - 在 &lt;code&gt;listA&lt;/code&gt; 中（从头节点开始）跳到交叉节点的节点数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;skipB&lt;/code&gt; - 在 &lt;code&gt;listB&lt;/code&gt; 中（从头节点开始）跳到交叉节点的节点数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;评测系统将根据这些输入创建链式数据结构，并将两个头节点 &lt;code&gt;headA&lt;/code&gt; 和 &lt;code&gt;headB&lt;/code&gt; 传递给你的程序。如果程序能够正确返回相交节点，那么你的解决方案将被  &lt;strong&gt;视作正确答案&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://assets.leetcode.com/uploads/2018/12/13/160_example_1.png&quot;&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/03/05/160_example_1_1.png&quot; alt=&quot;&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出：Intersected at &apos;8&apos;
解释：相交节点的值为 8 （注意，如果两个链表相交则不能为 0）。
从各自的表头开始算起，链表 A 为 [4,1,8,4,5]，链表 B 为 [5,6,1,8,4,5]。
在 A 中，相交节点前有 2 个节点；在 B 中，相交节点前有 3 个节点。
— 请注意相交节点的值不为 1，因为在链表 A 和链表 B 之中值为 1 的节点 (A 中第二个节点和 B 中第三个节点) 是不同的节点。换句话说，它们在内存中指向两个不同的位置，而链表 A 和链表 B 中值为 8 的节点 (A 中第三个节点，B 中第四个节点) 在内存中指向相同的位置。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://assets.leetcode.com/uploads/2018/12/13/160_example_2.png&quot;&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/03/05/160_example_2.png&quot; alt=&quot;&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出：Intersected at &apos;2&apos;
解释：相交节点的值为 2 （注意，如果两个链表相交则不能为 0）。
从各自的表头开始算起，链表 A 为 [1,9,1,2,4]，链表 B 为 [3,2,4]。
在 A 中，相交节点前有 3 个节点；在 B 中，相交节点前有 1 个节点。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://assets.leetcode.com/uploads/2018/12/13/160_example_3.png&quot;&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/14/160_example_3.png&quot; alt=&quot;&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出：No intersection
解释：从各自的表头开始算起，链表 A 为 [2,6,4]，链表 B 为 [1,5]。
由于这两个链表不相交，所以 intersectVal 必须为 0，而 skipA 和 skipB 可以是任意值。
这两个链表不相交，因此返回 null 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;listA&lt;/code&gt; 中节点数目为 &lt;code&gt;m&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;listB&lt;/code&gt; 中节点数目为 &lt;code&gt;n&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 3 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= Node.val &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= skipA &amp;lt;= m&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= skipB &amp;lt;= n&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果 &lt;code&gt;listA&lt;/code&gt; 和 &lt;code&gt;listB&lt;/code&gt; 没有交点，&lt;code&gt;intersectVal&lt;/code&gt; 为 &lt;code&gt;0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果 &lt;code&gt;listA&lt;/code&gt; 和 &lt;code&gt;listB&lt;/code&gt; 有交点，&lt;code&gt;intersectVal == listA[skipA] == listB[skipB]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你能否设计一个时间复杂度 &lt;code&gt;O(m + n)&lt;/code&gt; 、仅用 &lt;code&gt;O(1)&lt;/code&gt; 内存的解决方案？&lt;/p&gt;
&lt;h2&gt;1. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题解法倒是简单，作为第一道链表题，遍历完自己遍历别人就行，这样一定会和在相交点，如果不相交又一定会会和在None
# 主要是先熟悉一下ACM模式处理链表

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 将指针推至最后一个节点方便拼接
def get_tail(head):
    if not head:
        return None
    while head.next:
        head = head.next
    return head


def solution(headA: ListNode, headB: ListNode) -&amp;gt; ListNode:
    p = headA
    q = headB
    while p != q:
        p = p.next if p else headB
        q = q.next if q else headA
    return p


if __name__ == &quot;__main__&quot;:
    # 第一行：A的独有部分
    # 第二行：B的独有部分
    # 第三行：公共尾部；如果不相交就输入空行
    partA = input().strip()
    partB = input().strip()
    common = input().strip()

    numsA = list(map(int, partA.split(&apos;,&apos;))) if partA else []
    numsB = list(map(int, partB.split(&apos;,&apos;))) if partB else []
    numsC = list(map(int, common.split(&apos;,&apos;))) if common else []

    headA = build_linked_list(numsA)
    headB = build_linked_list(numsB)
    headC = build_linked_list(numsC)

    if headC:
        tailA = get_tail(headA)
        tailB = get_tail(headB)

        if tailA:
            tailA.next = headC
        else:
            headA = headC

        if tailB:
            tailB.next = headC
        else:
            headB = headC

    ans = solution(headA, headB)
    print(ans.val if ans else 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;如果是正常做思路非常简单的，但是如果要用ACM模式做，就要熟练掌握怎么写这些额外函数、怎么建链表，貌似精力都放在这上面来了。这题没让输出链表，不然还要写一个print_linked_list函数。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;不过是构建相交链表麻烦点，还有注意p可能为None，注意空值保护&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;反转链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;206. 反转链表&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你单链表的头节点 &lt;code&gt;head&lt;/code&gt; ，请你反转链表，并返回反转后的链表。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/02/19/rev1ex1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2,3,4,5]
输出：[5,4,3,2,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/02/19/rev1ex2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2]
输出：[2,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中节点的数目范围是 &lt;code&gt;[0, 5000]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-5000 &amp;lt;= Node.val &amp;lt;= 5000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 链表可以选用迭代或递归方式完成反转。你能否用两种方法解决这道题？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 三指针法在python中非常优雅]

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(head:ListNode)-&amp;gt;ListNode:
    prev = None
    curr = head
    # 多重赋值
    while curr:
        curr.next, prev, curr = prev, curr, curr.next
    # 三链表反转之后，prev是头结点
    return prev

if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    head = build_linked_list(nums)
    head = solution(head)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题多重赋值是Python最优雅的翻转链表方案，但是这一句要注意顺序问题。左边第一个目标是 curr.next，它会先把“旧 curr 的 next”改掉，然后再更新 prev 和 curr，这个顺序才是对的。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;秒了。注意多重赋值顺序&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;回文链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;234. 回文链表&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你一个单链表的头节点 &lt;code&gt;head&lt;/code&gt; ，请你判断该链表是否为回文链表。如果是，返回 &lt;code&gt;true&lt;/code&gt; ；否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/03/03/pal1linked-list.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2,2,1]
输出：true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/03/03/pal2linked-list.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2]
输出：false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中节点数目在范围&lt;code&gt;[1, 10^5]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= Node.val &amp;lt;= 9&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你能否用 &lt;code&gt;O(n)&lt;/code&gt; 时间复杂度和 &lt;code&gt;O(1)&lt;/code&gt; 空间复杂度解决此题？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 本题进阶要求O(n)时间复杂度和常数空间复杂度，思路其实很简单，就是快慢指针
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next


def solution(head:ListNode)-&amp;gt;bool:
    slow = head
    fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    # 这是奇偶区分，如果是奇数（也就是fast不会走到None），那么slow要从后一个开始翻转
    if fast:
        slow = slow.next
    # 三指针翻转
    curr=slow
    prev=None
    while curr:
        curr.next,curr,prev=prev,curr.next,curr
    # 我们可以从头开始比较，如果翻转后链表跑完之前都和原链表相等，那么一定为回文
    while prev:
        if prev.val!=head.val:
            return False
        prev=prev.next
        head=head.next
    return True

if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    head = build_linked_list(nums)
    print(solution(head))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;本题是找中点、翻转的合并。关键点在于根据fast的位置，可以判断出链表的奇偶，从而决定从什么位置开始翻转后半部分。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;h2&gt;差点又掉坑里了，一定要fast and fast.next才行，然后，加dummy是偶数中前/奇数中间，不加dummy是偶数中后/奇数中间&lt;/h2&gt;
&lt;h1&gt;环形链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;141. 环形链表&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你一个链表的头节点 &lt;code&gt;head&lt;/code&gt; ，判断链表中是否有环。&lt;/p&gt;
&lt;p&gt;如果链表中有某个节点，可以通过连续跟踪 &lt;code&gt;next&lt;/code&gt; 指针再次到达，则链表中存在环。 为了表示给定链表中的环，评测系统内部使用整数 &lt;code&gt;pos&lt;/code&gt; 来表示链表尾连接到链表中的位置（索引从 0 开始）。 &lt;strong&gt;注意：&lt;code&gt;pos&lt;/code&gt; 不作为参数进行传递&lt;/strong&gt;  。仅仅是为了标识链表的实际情况。&lt;/p&gt;
&lt;p&gt;&lt;em&gt;如果链表中存在环&lt;/em&gt; ，则返回 &lt;code&gt;true&lt;/code&gt; 。 否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [3,2,0,-4], pos = 1
输出：true
解释：链表中有一个环，其尾部连接到第二个节点。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist_test2.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2], pos = 0
输出：true
解释：链表中有一个环，其尾部连接到第一个节点。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist_test3.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1], pos = -1
输出：false
解释：链表中没有环。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中节点的数目范围是 &lt;code&gt;[0, 10^4]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^5 &amp;lt;= Node.val &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pos&lt;/code&gt; 为 &lt;code&gt;-1&lt;/code&gt; 或者链表中的一个  &lt;strong&gt;有效索引&lt;/strong&gt;  。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你能用 &lt;code&gt;O(1)&lt;/code&gt;（即，常量）内存解决此问题吗？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 本题也是经典结论，fast走两步，slow走一步，最终如果相遇则有环
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 这里升级了一下，直接返回头指针+节点列表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    nodes = []
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
        nodes.append(cur)
    return dummy.next, nodes

def solution(head:ListNode)-&amp;gt;bool:
    slow,fast = head,head
    while fast and fast.next:
        fast = fast.next.next
        slow = slow.next
        if fast == slow:
            return True
    return False

if __name__ == &quot;__main__&quot;:
    nums_line = input().strip()
    pos = int(input().strip())

    # 注意这里有个判空逻辑
    nums = list(map(int, nums_line.split(&apos;,&apos;))) if nums_line else []
    head, nodes = build_linked_list(nums)

    if pos != -1 and nodes:
        nodes[-1].next = nodes[pos]

    print(solution(head))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;注意这题的ACM模式，在建链表的时候也建一个nodes数组，这样方便我们直接看pos的位置。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;二刷就先不做了，一眼可以想到快慢指针法，但是定义输入比较麻烦，一般不会出这样的题目。&lt;/p&gt;
&lt;h2&gt;5. 三刷&lt;/h2&gt;
&lt;p&gt;出现了以下的错误写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(self,head:ListNode)-&amp;gt;bool:
    fast,slow = head,head
    while fast != slow:
        fast = fast.next.next
        slow = slow.next
    if slow:
        return True
    else:
        return False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这要是没有环直接就炸了，一定要记得一个fast and fast.next的逻辑，非常常用。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;环形链表 II&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;142. 环形链表 II&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个链表的头节点  &lt;code&gt;head&lt;/code&gt; ，返回链表开始入环的第一个节点。 &lt;em&gt;如果链表无环，则返回 &lt;code&gt;null&lt;/code&gt;。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;如果链表中有某个节点，可以通过连续跟踪 &lt;code&gt;next&lt;/code&gt; 指针再次到达，则链表中存在环。 为了表示给定链表中的环，评测系统内部使用整数 &lt;code&gt;pos&lt;/code&gt; 来表示链表尾连接到链表中的位置（ &lt;strong&gt;索引从 0 开始&lt;/strong&gt; ）。如果 &lt;code&gt;pos&lt;/code&gt; 是 &lt;code&gt;-1&lt;/code&gt;，则在该链表中没有环。 &lt;strong&gt;注意：&lt;code&gt;pos&lt;/code&gt; 不作为参数进行传递&lt;/strong&gt; ，仅仅是为了标识链表的实际情况。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不允许修改&lt;/strong&gt;  链表。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2018/12/07/circularlinkedlist.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [3,2,0,-4], pos = 1
输出：返回索引为 1 的链表节点
解释：链表中有一个环，其尾部连接到第二个节点。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist_test2.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2], pos = 0
输出：返回索引为 0 的链表节点
解释：链表中有一个环，其尾部连接到第一个节点。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist_test3.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1], pos = -1
输出：返回 null
解释：链表中没有环。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中节点的数目范围在范围 &lt;code&gt;[0, 10^4]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^5 &amp;lt;= Node.val &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pos&lt;/code&gt; 的值为 &lt;code&gt;-1&lt;/code&gt; 或者链表中的一个有效索引&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你是否可以使用 &lt;code&gt;O(1)&lt;/code&gt; 空间解决此题？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 本题不仅要看有没有环，还要看环在哪
# 实际上跟上一题基本没有区别，就是相遇之后，还有拍一个新的小兵从起点出发，然后再和slow相遇，对应的节点就是返回的点。
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 这里升级了一下，直接返回头指针+节点列表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    nodes = []
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
        nodes.append(cur)
    return dummy.next, nodes

def solution(head:ListNode)-&amp;gt;ListNode:
    slow,fast = head,head
    while fast and fast.next:
        fast = fast.next.next
        slow = slow.next
        if fast == slow:
            break
    # 派遣小兵
    start = head
    while slow !=start:
        slow = slow.next
        start = start.next
    return start

if __name__ == &quot;__main__&quot;:
    nums_line = input().strip()
    pos = int(input().strip())

    # 注意这里有个判空逻辑
    nums = list(map(int, nums_line.split(&apos;,&apos;))) if nums_line else []
    head, nodes = build_linked_list(nums)

    if pos != -1 and nodes:
        nodes[-1].next = nodes[pos]

    print(solution(head).val)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;没啥好说的，记结论&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;同上题，二刷跳过&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;合并两个有序链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;21. 合并两个有序链表&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;将两个升序链表合并为一个新的  &lt;strong&gt;升序&lt;/strong&gt;  链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/03/merge_ex1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：l1 = [1,2,4], l2 = [1,3,4]
输出：[1,1,2,3,4,4]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：l1 = [], l2 = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：l1 = [], l2 = [0]
输出：[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;两个链表的节点数目范围是 &lt;code&gt;[0, 50]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;l1&lt;/code&gt; 和 &lt;code&gt;l2&lt;/code&gt; 均按  &lt;strong&gt;非递减顺序&lt;/strong&gt;  排列&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 非常经典的合并链表题，可以从穿针引线的角度考虑，用新dummy去合并

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(l1:ListNode,l2:ListNode)-&amp;gt;ListNode:
    dummy = ListNode()
    p = dummy
    while l1 and l2:
        if l1.val&amp;gt;=l2.val:
            p.next = l2
            l2 =l2.next
        else:
            p.next = l1
            l1 = l1.next
        p = p.next
    if l1:
        p.next = l1
    if l2:
        p.next = l2
    return dummy.next

if __name__ == &quot;__main__&quot;:
    nums_l1 = list(map(int,input().strip().split(&apos;,&apos;)))
    nums_l2 = list(map(int,input().strip().split(&apos;,&apos;)))
    l1 = build_linked_list(nums_l1)
    l2 = build_linked_list(nums_l2)
    head = solution(l1,l2)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;秒了&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;两数相加&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;2. 两数相加&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你两个  &lt;strong&gt;非空&lt;/strong&gt;  的链表，表示两个非负的整数。它们每位数字都是按照  &lt;strong&gt;逆序&lt;/strong&gt;  的方式存储的，并且每个节点只能存储  &lt;strong&gt;一位&lt;/strong&gt;  数字。&lt;/p&gt;
&lt;p&gt;请你将两个数相加，并以相同形式返回一个表示和的链表。&lt;/p&gt;
&lt;p&gt;你可以假设除了数字 0 之外，这两个数都不会以 0 开头。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2021/01/02/addtwonumber1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：l1 = [2,4,3], l2 = [5,6,4]
输出：[7,0,8]
解释：342 + 465 = 807.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：l1 = [0], l2 = [0]
输出：[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
输出：[8,9,9,9,0,0,0,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个链表中的节点数在范围 &lt;code&gt;[1, 100]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= Node.val &amp;lt;= 9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;题目数据保证列表表示的数字不含前导零&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题就是竖式加法，还贴心给你逆序好了，这种板子属于必须背下来的地步

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(l1:ListNode,l2:ListNode)-&amp;gt;ListNode:
    carry = 0
    dummy = ListNode()
    p = dummy
    while l1 or l2 or carry:
        a = l1.val if l1 else 0
        b = l2.val if l2 else 0
        total = a+b+carry
        carry = total//10
        p.next= ListNode(total%10)
        if l1:
            l1 = l1.next
        if l2:
            l2 = l2.next
        p = p.next
    return dummy.next

if __name__ == &quot;__main__&quot;:
    nums_l1 = list(map(int,input().strip().split(&apos;,&apos;)))
    nums_l2 = list(map(int,input().strip().split(&apos;,&apos;)))
    l1 = build_linked_list(nums_l1)
    l2 = build_linked_list(nums_l2)
    head = solution(l1,l2)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;依旧直接背板，一定要熟练。易错点两个，一个是l1不为空才能next，另一个是carry和total都是本轮算本轮的，千万别+=&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;二刷犯下的错误是next之前忘了判断l1 or l2是否已经为空了。其他倒是没什么可说的。&lt;/p&gt;
&lt;h2&gt;5. 拓展1 - 正序字符串&lt;/h2&gt;
&lt;p&gt;如果给的是正序两个字符串怎么办，这是一般的符合直觉的大数加法题，解法也合直觉，从末尾往前加，最后返回答案的时候进行一次翻转。注意用ord(字符)-ord(&apos;0&apos;)转数字即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def add_strings(a: str, b: str) -&amp;gt; str:
    i = len(a) - 1
    j = len(b) - 1
    carry = 0
    ans = []

    while i &amp;gt;= 0 or j &amp;gt;= 0 or carry:
        x = ord(a[i]) - ord(&apos;0&apos;) if i &amp;gt;= 0 else 0
        y = ord(b[j]) - ord(&apos;0&apos;) if j &amp;gt;= 0 else 0

        total = x + y + carry
        ans.append(str(total % 10))
        carry = total // 10

        i -= 1
        j -= 1

    return &apos;&apos;.join(reversed(ans))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;删除链表的倒数第 N 个结点&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;19. 删除链表的倒数第 N 个结点&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个链表，删除链表的倒数第 &lt;code&gt;n&lt;/code&gt; 个结点，并且返回链表的头结点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/03/remove_ex1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2,3,4,5], n = 2
输出：[1,2,3,5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1], n = 1
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2], n = 1
输出：[1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中结点的数目为 &lt;code&gt;sz&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= sz &amp;lt;= 30&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= sz&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你能尝试使用一趟扫描实现吗？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 双指针当尺子就行了
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(head:ListNode,n:int)-&amp;gt;ListNode:
    # 有可能删除头结点，所以用dummy
    dummy = ListNode()
    dummy.next =head
    slow,fast = dummy,dummy
    for _ in range(n):
        fast = fast.next
    while fast.next:
        slow = slow.next
        fast = fast.next
    # 现在slow在待删除元素前面
    slow.next= slow.next.next
    return dummy.next

if __name__ ==&quot;__main__&quot;:
    nums_line = input().strip()
    n = int(input().strip())

    # 注意这里有个判空逻辑
    nums = list(map(int, nums_line.split(&apos;,&apos;))) if nums_line else []
    head = build_linked_list(nums)
    head = solution(head,n)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;注意点只有一个，就是删除节点要加dummy，因为头也可能被删&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;注意点同上，不用重复写了&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;两两交换列表中的节点&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;24. 两两交换链表中的节点&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个链表，两两交换其中相邻的节点，并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题（即，只能进行节点交换）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/03/swap_ex1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2,3,4]
输出：[2,1,4,3]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1]
输出：[1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中节点的数目在范围 &lt;code&gt;[0, 100]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 1 · 直接翻转&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题有两种解法，一种是递归，另一种是每次记住翻转前的头结点作为tail，连上curr（curr会天然跑到下一个节点）
# 先来最直观的
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(head:ListNode)-&amp;gt;ListNode:
    if not head or not head.next:
        return head
    dummy = ListNode()
    dummy.next = head
    list_pre = dummy
    curr = head
    while curr and curr.next:
        tail = curr
        prev = None
        # 翻转两次
        for _ in range(2):
            curr.next,curr,prev = prev,curr.next,curr
        # 接上翻转过的链表
        tail.next = curr
        list_pre.next = prev
        list_pre = tail
    return dummy.next

if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    head = build_linked_list(nums)
    head = solution(head)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 递归处理&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 递归的解法，我们需要假设solution函数返回的就是两两交换好的节点，所以我们只要排当前的两个，然后后面交给递归黑箱就行了
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(head:ListNode)-&amp;gt;ListNode:
    # 同样小于两个节点的话不用找了
    if not head or not head.next:
        return head
    # 调转的时候指向黑箱递归
    new_head = head.next
    head.next = solution(new_head.next)
    new_head.next = head
    return new_head


if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    head = build_linked_list(nums)
    head = solution(head)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题第一种解法在解决k个翻转的时候也很给力，递归的解法最容易理解代码最简洁，都需要掌握&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;递归秒了。注意如果准备直接翻转的话要自己额外定义tail（也就是每次翻转的curr）&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;k个一组翻转链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;25. K 个一组翻转链表&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给你链表的头节点 &lt;code&gt;head&lt;/code&gt; ，每 &lt;code&gt;k&lt;/code&gt; 个节点一组进行翻转，请你返回修改后的链表。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;k&lt;/code&gt; 是一个正整数，它的值小于或等于链表的长度。如果节点总数不是 &lt;code&gt;k&lt;/code&gt; 的整数倍，那么请将最后剩余的节点保持原有顺序。&lt;/p&gt;
&lt;p&gt;你不能只是单纯的改变节点内部的值，而是需要实际进行节点交换。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/03/reverse_ex1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2,3,4,5], k = 2
输出：[2,1,4,3,5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/03/reverse_ex2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [1,2,3,4,5], k = 3
输出：[3,2,1,4,5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中的节点数目为 &lt;code&gt;n&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= k &amp;lt;= n &amp;lt;= 5000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= Node.val &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你可以设计一个只用 &lt;code&gt;O(1)&lt;/code&gt; 额外内存空间的算法解决此问题吗？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 我们先按照两个一组的解法改一下，直接做成k个一组。不同点在于我们可能需要先遍历一下，数数节点个数，这样才知道我们需要进行多少次k翻转
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(head:ListNode,k:int)-&amp;gt;ListNode:
    dummy = ListNode()
    dummy.next = head
    ct = head
    count = 0
    while ct:
        ct = ct.next
        count += 1
    times = count//k
    # 开始times次翻转
    curr = head
    list_pre = dummy
    for _ in range(times):
        # k翻转准备工作，标记tail
        tail = curr
        prev = None
        # k翻转本题
        for _ in range(k):
            curr.next,curr,prev = prev,curr.next,curr
        # 接上节点
        list_pre.next = prev
        tail.next = curr
        # 准备下次k翻转
        list_pre = tail
    return dummy.next



if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input().strip())
    head = build_linked_list(nums)
    head = solution(head,k)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 递归&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 跟上题一样，递归思路自然是最简单。先检查够不够k，不够就返回。然后进行k个翻转，将下一个连接到黑箱就行。
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(head: ListNode, k: int) -&amp;gt; ListNode:
    cur = head
    for _ in range(k):
        if not cur:
            return head
        cur = cur.next

    prev = None
    curr = head
    for _ in range(k):
        curr.next, curr, prev = prev, curr.next, curr

    head.next = solution(curr, k)
    return prev


if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    k = int(input().strip())
    head = build_linked_list(nums)
    head = solution(head,k)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;和上提一样，一个直接做，一个递归。直接做有助于思考三指针翻转后的指针都在哪，递归则是容易理解好做的思路。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;递归很容易想到，这次我用了一个辅助函数来统计k，和递归内走k格子看空不空一样。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;随机链表的复制&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;138. 随机链表的复制&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个长度为 &lt;code&gt;n&lt;/code&gt; 的链表，每个节点包含一个额外增加的随机指针 &lt;code&gt;random&lt;/code&gt; ，该指针可以指向链表中的任何节点或空节点。&lt;/p&gt;
&lt;p&gt;构造这个链表的  &lt;strong&gt;&lt;a href=&quot;https://baike.baidu.com/item/%E6%B7%B1%E6%8B%B7%E8%B4%9D/22785317?fr=aladdin&quot;&gt;深拷贝&lt;/a&gt;&lt;/strong&gt; 。 深拷贝应该正好由 &lt;code&gt;n&lt;/code&gt; 个  &lt;strong&gt;全新&lt;/strong&gt;  节点组成，其中每个新节点的值都设为其对应的原节点的值。新节点的 &lt;code&gt;next&lt;/code&gt; 指针和 &lt;code&gt;random&lt;/code&gt; 指针也都应指向复制链表中的新节点，并使原链表和复制链表中的这些指针能够表示相同的链表状态。 &lt;strong&gt;复制链表中的指针都不应指向原链表中的节点&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;例如，如果原链表中有 &lt;code&gt;X&lt;/code&gt; 和 &lt;code&gt;Y&lt;/code&gt; 两个节点，其中 &lt;code&gt;X.random --&amp;gt; Y&lt;/code&gt; 。那么在复制链表中对应的两个节点 &lt;code&gt;x&lt;/code&gt; 和 &lt;code&gt;y&lt;/code&gt; ，同样有 &lt;code&gt;x.random --&amp;gt; y&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;返回复制链表的头节点。&lt;/p&gt;
&lt;p&gt;用一个由 &lt;code&gt;n&lt;/code&gt; 个节点组成的链表来表示输入/输出中的链表。每个节点用一个 &lt;code&gt;[val, random_index]&lt;/code&gt; 表示：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;val&lt;/code&gt;：一个表示 &lt;code&gt;Node.val&lt;/code&gt; 的整数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;random_index&lt;/code&gt;：随机指针指向的节点索引（范围从 &lt;code&gt;0&lt;/code&gt; 到 &lt;code&gt;n-1&lt;/code&gt;）；如果不指向任何节点，则为  &lt;code&gt;null&lt;/code&gt; 。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你的代码  &lt;strong&gt;只&lt;/strong&gt;  接受原链表的头节点 &lt;code&gt;head&lt;/code&gt; 作为传入参数。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2020/01/09/e1.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [[7,null],[13,0],[11,4],[10,2],[1,0]]
输出：[[7,null],[13,0],[11,4],[10,2],[1,0]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2020/01/09/e2.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [[1,1],[2,1]]
输出：[[1,1],[2,1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2020/01/09/e3.png&quot; alt=&quot;&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [[3,null],[3,0],[3,null]]
输出：[[3,null],[3,0],[3,null]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= n &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= Node.val &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Node.random&lt;/code&gt; 为 &lt;code&gt;null&lt;/code&gt; 或指向链表中的节点。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 随机链表的复制，要求还原原链表的所有指针。其实所有数据结构的复制都是一样的，先复制节点内容，再复制关系。用哈希表做一个旧对新的存储即可。
import ast


class Node:
    def __init__(self, x: int, next: &apos;Node&apos; = None, random: &apos;Node&apos; = None):
        self.val = int(x)
        self.next = next
        self.random = random


def build_random_list(data):
    if not data:
        return None

    nodes = [Node(val) for val, _ in data]

    for i in range(len(nodes) - 1):
        nodes[i].next = nodes[i + 1]

    for i, (_, random_idx) in enumerate(data):
        if random_idx is not None:
            nodes[i].random = nodes[random_idx]

    return nodes[0]


def print_random_list(head):
    nodes = []
    node_to_idx = {}
    p = head
    idx = 0
    while p:
        nodes.append(p)
        node_to_idx[p] = idx
        p = p.next
        idx += 1

    ans = []
    for node in nodes:
        random_idx = node_to_idx[node.random] if node.random else None
        ans.append([node.val, random_idx])
    print(ans)


def solution(head: Node) -&amp;gt; Node:
    # 不用deepcopy外挂的话，记住数据结构复制就一个哈希表+两次遍历：第一次遍历专门克隆节点，借助哈希表把原始节点和克隆节点的映射存储起来；第二次专门组装节点，照着原数据结构的样子，把克隆节点的指针组装起来
    originToClone = {}
    # 第一遍遍历，克隆节点
    p = head
    while p:
        if p not in originToClone:
            originToClone[p] = Node(p.val)
        p = p.next
    # 第二次遍历，组装节点
    p = head
    while p:
        # 克隆之孩子等于孩子之克隆
        if p.next:
            originToClone[p].next = originToClone[p.next]
        if p.random:
            originToClone[p].random = originToClone[p.random]
        p = p.next
    return originToClone.get(head)


if __name__ == &quot;__main__&quot;:
    line = input().strip()
    data = ast.literal_eval(line.replace(&quot;null&quot;, &quot;None&quot;)) if line else []
    head = build_random_list(data)
    head = solution(head)
    print_random_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;重点看solution部分，但是也可以看一下python的厉害。先用replace换成可以解析的字面量，然后用ast.literal_eval(...)，可以直接解析，将字符串形式的嵌套列表，转化为真列表。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;没有再刷了，输入输出弄的太麻烦，思路其实很简单。originToClone[p].next = originToClone[p.next]这句话理解清楚就行。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;排序链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;148. 排序链表&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你链表的头结点 &lt;code&gt;head&lt;/code&gt; ，请将其按  &lt;strong&gt;升序&lt;/strong&gt;  排列并返回  &lt;strong&gt;排序后的链表&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/09/14/sort_list_1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [4,2,1,3]
输出：[1,2,3,4]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/09/14/sort_list_2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = [-1,5,3,4,0]
输出：[-1,0,3,4,5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：head = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表中节点的数目在范围 &lt;code&gt;[0, 5 * 10^4]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^5 &amp;lt;= Node.val &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;**进阶：**你可以在 &lt;code&gt;O(n log n)&lt;/code&gt; 时间复杂度和常数级空间复杂度下，对链表进行排序吗？&lt;/p&gt;
&lt;h2&gt;2. 题解 1 · 数组复制&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 进阶要求是，在nlogn时间范围内完成，也就是快排。最简单能想到的是，建一个数组，排序数组，然后按数组重建链表
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表 + 数组
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    nodes = []
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
        nodes.append(cur.val)
    return dummy.next, nodes

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)


if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    head, nodes = build_linked_list(nums)
    nodes.sort()
    head ,nodes = build_linked_list(nodes)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 归并排序&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 但是，这一题可以更简化到Onlogn+logn额外栈空间，也是力扣真的想考的方法，归并排序。
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 尾插法建立链表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

def solution(head: ListNode) -&amp;gt; ListNode:
    if not head or not head.next:
        return head

    # 快慢指针找中点，并断开链表
    slow, fast = head, head.next
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next

    mid = slow.next
    slow.next = None

    left = solution(head)
    right = solution(mid)

    # 合并两个有序链表
    dummy = ListNode()
    p = dummy
    while left and right:
        if left.val &amp;lt; right.val:
            p.next = left
            left = left.next
        else:
            p.next = right
            right = right.next
        p = p.next

    p.next = left if left else right
    return dummy.next


if __name__ ==&quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    head = build_linked_list(nums)
    head = solution(head)
    print_linked_list(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这一题想看的是解法二，解法一纯钻空子。注意这里有三步，首先设置递归出口，然后快慢指针寻找中点（注意这里的fast设置在head.next的起点，就可以自然让slow停在mid前面，不用借助dummy），最后递归排序两边。后面是标准的排序两个有序链表的过程。&lt;/li&gt;
&lt;li&gt;然后就是力扣里面函数本身带self，记得递归的时候self.func&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;想到用分治了，不断进行下去直到两边都有序。但是问题是忘记了最关键的部分，也就是mid = slow.next和slow.next = None。为什么要先断开后面的链表？否则左侧会一直走到右侧，就不是想要的合并两边有序链表了。不用担心会被切碎碎，因为后面合并的步骤会组装起来。&lt;/li&gt;
&lt;li&gt;虽然跟这题关系不大，但是合并时p.next = left if left else right可以体现python的简洁美。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;6. 拓展&lt;/h2&gt;
&lt;p&gt;这一题，就是经典的归并排序链表版。我们下面可以给出数组版本的归并排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(nums: list[int]) -&amp;gt; list[int]:
    if len(nums) &amp;lt;= 1:
        return nums

    mid = len(nums) // 2
    left = solution(nums[:mid])
    right = solution(nums[mid:])

    return merge(left, right)


def merge(left: list[int], right: list[int]) -&amp;gt; list[int]:
    i, j = 0, 0
    ans = []

    while i &amp;lt; len(left) and j &amp;lt; len(right):
        if left[i] &amp;lt;= right[j]:
            ans.append(left[i])
            i += 1
        else:
            ans.append(right[j])
            j += 1

    ans.extend(left[i:])
    ans.extend(right[j:])
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一般我们喜欢把merge函数拎出去，这样看起来更清楚。本题的链表归并排序，也可以写成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next


def merge(headA: ListNode, headB: ListNode) -&amp;gt; ListNode:
    dummy = ListNode()
    p = dummy

    while headA and headB:
        if headA.val &amp;lt;= headB.val:
            p.next = headA
            headA = headA.next
        else:
            p.next = headB
            headB = headB.next
        p = p.next

    p.next = headA if headA else headB
    return dummy.next


def solution(head: ListNode) -&amp;gt; ListNode:
    if not head or not head.next:
        return head

    dummy = ListNode()
    dummy.next = head
    slow, fast = dummy, dummy

    # 找中点，断开成两半
    while fast and fast.next:
        fast = fast.next.next
        slow = slow.next

    mid = slow.next
    slow.next = None

    left = solution(head)
    right = solution(mid)

    return merge(left, right)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;7. 题解3&lt;/h2&gt;
&lt;p&gt;竟然有人面试被问到了链表的快速排序，那就也在这里做一下。一般快排做法是选枢纽，断三段，最后递归排序左右两段，然后拼接。在链表中，这个pivot一般就取头结点（数组中是randomint）。（不用 tail 也能 append，但是每次需要遍历到末尾。为了方便append，我们改动一下尾插法，让每次返回的一个元组，包含head和tail节点）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val = 0 ,next = None):
        self.val = val
        self.next = next

def _append(head,tail,node):
    node.next = None
    if not head:
        return node,node
    tail.next = node
    return head,node

def _concat(left,mid,right):
    dummy = ListNode(0)
    curr = dummy
    for h in (left,mid,right):
        if h:
            curr.next = h
            while curr.next:
                curr = curr.next
    return dummy.next

def sortList(head:ListNode):
    if not head or not head.next:
        return head
    pivot = head
    less = eq = greater = None
    less_tail = eq_tail = greater_tail = None

    curr = head
    while curr:
        nxt = curr.next
        if curr.val&amp;lt;pivot.val:
            less,less_tail = _append(less,less_tail,curr)
        elif curr.val &amp;gt; pivot.val:
            greater, greater_tail = _append(greater,greater_tail,curr)
        else:
            eq,eq_tail = _append(eq,eq_tail,curr)
        curr = nxt
    
    left = sortList(less)
    right = sortList(greater)

    return _concat(left,eq,right)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;合并 K 个升序链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;23. 合并 K 个升序链表&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给你一个链表数组，每个链表都已经按升序排列。&lt;/p&gt;
&lt;p&gt;请你将所有链表合并到一个升序链表中，返回合并后的链表。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：lists = [[1,4,5],[1,3,4],[2,6]]
输出：[1,1,2,3,4,4,5,6]
解释：链表数组如下：
[
  1-&amp;gt;4-&amp;gt;5,
  1-&amp;gt;3-&amp;gt;4,
  2-&amp;gt;6
]
将它们合并到一个有序链表中得到。
1-&amp;gt;1-&amp;gt;2-&amp;gt;3-&amp;gt;4-&amp;gt;4-&amp;gt;5-&amp;gt;6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：lists = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：lists = [[]]
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;k == lists.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= k &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= lists[i].length &amp;lt;= 500&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= lists[i][j] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lists[i]&lt;/code&gt; 按  &lt;strong&gt;升序&lt;/strong&gt;  排列&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lists[i].length&lt;/code&gt; 的总和不超过 &lt;code&gt;10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 1 · 堆排序&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 合并k个升序链表，我们可以用堆来做。将值和节点对入堆，然后每次拿出最小的让p指向。
# 还需要放一个idx，这是因为堆比完第一个相同自动比第二个。这是万万不行的，需要在二位弄一个idx保护。
import ast
import heapq
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 这里升级了一下，直接返回头指针
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)


def solution(lists: list[ListNode]) -&amp;gt; ListNode:
    heap = []
    dummy = ListNode()
    p = dummy

    # 建堆
    for i, node in enumerate(lists):
        if node:
            heapq.heappush(heap, (node.val, i, node))

    # 弹堆，每次弹之后将弹出的节点下一个节点入堆
    while heap:
        _, i, node = heapq.heappop(heap)
        p.next = node
        p = p.next

        if node.next:
            heapq.heappush(heap, (node.next.val, i, node.next))

    return dummy.next


if __name__ == &quot;__main__&quot;:
    # 输入二维数组
    line = input().strip()
    data = ast.literal_eval(line)
    headlist = []
    for nums in data:
        headlist.append(build_linked_list(nums))
    print_linked_list(solution(headlist))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 分治&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 合并k个升序链表，我们可以用堆来做。将值和节点对入堆，然后每次拿出最小的让p指向。
# 还需要放一个idx，这是因为堆比完第一个相同自动比第二个。这是万万不行的，需要在二位弄一个idx保护。
import ast
import heapq
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 这里升级了一下，直接返回头指针
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
    return dummy.next

# 打印链表
def print_linked_list(head):
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)


def merge2Lists(l1,l2):
    # 穿针引线
    dummy = ListNode(-1)
    p = dummy
    p1, p2 =l1, l2
    while p1 and p2:
        if p1.val&amp;lt;=p2.val:
            p.next = p1
            p1 = p1.next
        else:
            p.next = p2
            p2 = p2.next
        p = p.next

    # 看看有没有合并完毕
    p.next = p1 if p1 else p2
    return dummy.next

# 辅助函数，让List[start,end)也合并成有序链表
def mergeLists(lists,start,end):
    # 就一个表
    if start == end:
        return lists[start]
    if start &amp;gt; end:
        return None
    # 折半合并
    mid = (start + end) //2
    # 合并左右半边
    # 这里left包含一下mid
    left = mergeLists(lists,start,mid)
    right = mergeLists(lists,mid+1,end)
    return merge2Lists(left,right)



def mergeKLists(lists:list[ListNode]) -&amp;gt; ListNode:
    # 不借助优先队列，可以采用分治思想
    # 从中间切开将两边变得有序，然后再合并
    if len(lists) == 0:
        return None
    left = 0
    right = len(lists)-1
    return mergeLists(lists,left,right)


if __name__ == &quot;__main__&quot;:
    # 输入二维数组
    line = input().strip()
    data = ast.literal_eval(line)
    headlist = []
    for nums in data:
        headlist.append(build_linked_list(nums))
    print_linked_list(mergeKLists(headlist))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;第一种方案是用堆自动排序，体现了堆可以存很多种不同的结果；第二种则是分治算法的体现，理解稍微复杂，实际上就是分成两边做让递归数尽可能矮。本质上也是先分两边不断递归排完left、right，然后进行两个有序链表合并的归并。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;理解了归并排序之后，这次也能轻松写出归并算法了，但是递归出口还是写错了。要注意两个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;递归出口不用写2，可以交给后面统一分治；&lt;/li&gt;
&lt;li&gt;递归出口返回的一定要是函数期望返回的类型，这是底线。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;本题比上述题解更简洁的做法如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

class ListNode:
    def __init__(self, val = 0, next = None):
        self.val = val
        self.next = next

# 从列表建立链表
def build_list(nums:list):
    dummy = ListNode(0)
    p = dummy
    for num in nums:
        p.next = ListNode(num)
        p = p.next
    return dummy.next

# 打印链表
def print_list(head:ListNode) -&amp;gt; None:
    ans = []
    while head:
        ans.append(head.val)
        head = head.next
    print(ans)

# 辅助函数，合并两个有序链表
def merge(l1:ListNode,l2:ListNode) -&amp;gt; ListNode:
    dummy = ListNode()
    p = dummy
    while l1 and l2:
        if l1.val &amp;lt;= l2.val:
            p.next = l1
            l1 = l1.next
        else:
            p.next = l2
            l2 = l2.next
        p = p.next
    p.next = l1 if l1 else l2
    return dummy.next


def solution(lists:list[ListNode]) -&amp;gt; ListNode:
    n = len(lists)
    if n == 1:
        return lists[0]
    if n == 0:
        return None
    mid = n//2
    left = solution(lists[:mid])
    right = solution(lists[mid:])
    return merge(left,right)
        

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    lists = []
    for num in nums:
        lists.append(build_list(num))
    print_list(solution(lists))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;LRU缓存&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;146. LRU 缓存&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;请你设计并实现一个满足  &lt;a href=&quot;https://baike.baidu.com/item/LRU&quot;&gt;LRU (最近最少使用) 缓存&lt;/a&gt; 约束的数据结构。&lt;/p&gt;
&lt;p&gt;实现 &lt;code&gt;LRUCache&lt;/code&gt; 类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LRUCache(int capacity)&lt;/code&gt; 以  &lt;strong&gt;正整数&lt;/strong&gt;  作为容量 &lt;code&gt;capacity&lt;/code&gt; 初始化 LRU 缓存&lt;/li&gt;
&lt;li&gt;&lt;code&gt;int get(int key)&lt;/code&gt; 如果关键字 &lt;code&gt;key&lt;/code&gt; 存在于缓存中，则返回关键字的值，否则返回 &lt;code&gt;-1&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;void put(int key, int value)&lt;/code&gt; 如果关键字 &lt;code&gt;key&lt;/code&gt; 已经存在，则变更其数据值 &lt;code&gt;value&lt;/code&gt; ；如果不存在，则向缓存中插入该组 &lt;code&gt;key-value&lt;/code&gt; 。如果插入操作导致关键字数量超过 &lt;code&gt;capacity&lt;/code&gt; ，则应该  &lt;strong&gt;逐出&lt;/strong&gt;  最久未使用的关键字。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;函数 &lt;code&gt;get&lt;/code&gt; 和 &lt;code&gt;put&lt;/code&gt; 必须以 &lt;code&gt;O(1)&lt;/code&gt; 的平均时间复杂度运行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入
[&quot;LRUCache&quot;, &quot;put&quot;, &quot;put&quot;, &quot;get&quot;, &quot;put&quot;, &quot;get&quot;, &quot;put&quot;, &quot;get&quot;, &quot;get&quot;, &quot;get&quot;]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废，缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废，缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= capacity &amp;lt;= 3000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= key &amp;lt;= 10000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= value &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;最多调用 &lt;code&gt;2 * 10^5&lt;/code&gt; 次 &lt;code&gt;get&lt;/code&gt; 和 &lt;code&gt;put&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题非常非常典型，实现方法是双链表+哈希表。哈希表负责快查，双向链表维持最近使用顺序。双向链表的顺序是，头部为最新使用，尾部为最久没使用。如果get就将元素移动到头部，put直接放在头部，容量超出了，就删除尾部。
# 现在，请背板
import ast


class Node:
    # 初始化双链表节点
    def __init__(self, key=0, val=0):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None


class LRUCache:
    # 在此基础上初始化容量、cache哈希表、头尾节点
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}

        self.dummy_head = Node()
        self.dummy_tail = Node()
        self.dummy_head.next = self.dummy_tail
        self.dummy_tail.prev = self.dummy_head

    def remove(self, node):
        # 双链表删除
        node.prev.next = node.next
        node.next.prev = node.prev

    # 加入最前面
    def push_front(self, node):
        node.next = self.dummy_head.next
        node.next.prev = node
        self.dummy_head.next = node
        node.prev = self.dummy_head

    # 访问后移动到前面
    def move_to_front(self, node):
        self.remove(node)
        self.push_front(node)

    def pop_tail(self):
        node = self.dummy_tail.prev
        self.remove(node)
        return node

    def get(self, key: int) -&amp;gt; int:
        # 有返回值没有返回-1
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self.move_to_front(node)
        return node.val

    def put(self, key: int, value: int) -&amp;gt; None:
        # 如果有，更新值
        if key in self.cache:
            node = self.cache[key]
            node.val = value
            self.move_to_front(node)
            return
        # 没有的话新建
        node = Node(key, value)
        self.cache[key] = node
        self.push_front(node)

        if len(self.cache) &amp;gt; self.capacity:
            removed = self.pop_tail()
            del self.cache[removed.key]


if __name__ == &quot;__main__&quot;:
    ops = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    args = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))

    ans = []
    lru = None

    for op, arg in zip(ops, args):
        if op == &quot;LRUCache&quot;:
            lru = LRUCache(arg[0])
            ans.append(None)
        elif op == &quot;put&quot;:
            lru.put(arg[0], arg[1])
            ans.append(None)
        elif op == &quot;get&quot;:
            ans.append(lru.get(arg[0]))

    print(str(ans).replace(&quot;None&quot;, &quot;null&quot;))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题代码看似复杂，实际上就是打一个双链表板子。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;最近听到面试的时候经常会被问LRU的升级版，比如支持并发的LRU，支持TTL的LRU，很恶心。接下来给一个带TTL和并发锁的最简单升级版：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import time
import threading


class Node:
    def __init__(self, key=0, val=0, expire_at=float(&quot;inf&quot;)):
        self.key = key
        self.val = val
        self.expire_at = expire_at
        self.prev = None
        self.next = None


class LRUCacheTTL:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}
        self.lock = threading.RLock()

        self.head = Node()
        self.tail = Node()
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _add_to_head(self, node):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def _move_to_head(self, node):
        self._remove(node)
        self._add_to_head(node)

    def _pop_tail(self):
        node = self.tail.prev
        self._remove(node)
        return node

    def _expired(self, node):
        return node.expire_at &amp;lt; time.time()

    def get(self, key):
        with self.lock:
            node = self.cache.get(key)
            if not node:
                return -1
            if self._expired(node):
                self._remove(node)
                del self.cache[key]
                return -1
            self._move_to_head(node)
            return node.val

    def put(self, key, value, ttl=None):
        with self.lock:
            expire_at = float(&quot;inf&quot;) if ttl is None else time.time() + ttl

            if key in self.cache:
                node = self.cache[key]
                node.val = value
                node.expire_at = expire_at
                self._move_to_head(node)
                return

            node = Node(key, value, expire_at)
            self.cache[key] = node
            self._add_to_head(node)

            if len(self.cache) &amp;gt; self.capacity:
                removed = self._pop_tail()
                del self.cache[removed.key]

&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树的中序遍历&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;94. 二叉树的中序遍历&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个二叉树的根节点 &lt;code&gt;root&lt;/code&gt; ，返回 &lt;em&gt;它的  &lt;strong&gt;中序&lt;/strong&gt;  遍历&lt;/em&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/09/15/inorder_1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,null,2,3]
输出：[1,3,2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1]
输出：[1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目在范围 &lt;code&gt;[0, 100]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶:&lt;/strong&gt;  递归算法很简单，你可以通过迭代算法完成吗？&lt;/p&gt;
&lt;hr /&gt;
&lt;pre&gt;&lt;code&gt;# 中序遍历的递归很简单，关键是要建立树
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root:TreeNode)-&amp;gt;list:
    ans = []
    def inorder(node):
        if not node:
            return None
        inorder(node.left)
        ans.append(node.val)
        inorder(node.right)
    inorder(root)
    return ans

if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;中序遍历不难，主要是ACM模式读取和建树。ast读取列表，replace把null换成None。&lt;/li&gt;
&lt;li&gt;板子的易错点：层序建树要时刻用i监督是否超过data长度，打印层序的时候别忘了先判空再入队，然后就是空树的各种边界保护。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;秒了。树的二刷只写核心代码，不过确实也发现自己写不好建树了。其实就是注意用i标识数组有没有走完，遇到None的时候不管（我们的数组里有None，但是不能挂）&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树的最大深度&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;104. 二叉树的最大深度&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个二叉树 &lt;code&gt;root&lt;/code&gt; ，返回其最大深度。&lt;/p&gt;
&lt;p&gt;二叉树的  &lt;strong&gt;最大深度&lt;/strong&gt;  是指从根节点到最远叶子节点的最长路径上的节点数。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/11/26/tmp-tree.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [3,9,20,null,null,15,7]
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,null,2]
输出：2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点的数量在 &lt;code&gt;[0, 10^4]&lt;/code&gt; 区间内。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 两种方法，要么直接带着深度参数dfs，要么nonlocal一个depth不传参数，这样记得递归完成后回退-1
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root


def solution(root:TreeNode)-&amp;gt;int:
    max_depth = 0
    def dfs(node,depth):
        nonlocal max_depth
        if not node:
            return
        max_depth = max(max_depth,depth)
        dfs(node.left,depth+1)
        dfs(node.right,depth+1)
    dfs(root,1)
    return max_depth


if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;秒了&lt;/p&gt;
&lt;h2&gt;4. 题解2&lt;/h2&gt;
&lt;p&gt;最简单的方案其实是让dfs直接返回深度&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val = 0, left = None, right = None):
        self.val = val
        self.left = left
        self.right = right

def solution(root:TreeNode):
    def dfs(node):
        if not node:
            return 0
        return 1+ max(dfs(node.left),dfs(node.right))
    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;翻转二叉树&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;226. 翻转二叉树&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你一棵二叉树的根节点 &lt;code&gt;root&lt;/code&gt; ，翻转这棵二叉树，并返回其根节点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/03/14/invert1-tree.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [4,2,7,1,3,6,9]
输出：[4,7,2,9,6,3,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/03/14/invert2-tree.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [2,1,3]
输出：[2,3,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目范围在 &lt;code&gt;[0, 100]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 其实可以建树的时候就先右后左。不过我们默认模版是不动的。于是我们可以dfs一下，然后有左右孩子的换一下
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root:TreeNode)-&amp;gt;TreeNode:
    def dfs(node:TreeNode):
        if not node:
            return None
        node.left,node.right = node.right,node.left
        dfs(node.left)
        dfs(node.right)
    dfs(root)
    return root

if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    solution(root)
    print_tree(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;有时候会担心翻转之后dfs会不会乱了，或者需要调整成先right后left。其实不用，dfs只是为了遍历，即使左右换了，还是可以遍历，只不过遍历顺序变了。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;秒了&lt;/p&gt;
&lt;h2&gt;5. 解法2&lt;/h2&gt;
&lt;p&gt;学完了树形dp之后，这题更自然会让我想到从下往上组装，因此可以解法如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val = 0, left = None, right = None):
        self.val = val
        self.left = left
        self.right = right

def solution(root:TreeNode):
    def dfs(node):
        if not node:
            return
        left = dfs(node.left)
        right = dfs(node.right)
        node.left = right
        node.right = left
        return node
    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;对称二叉树&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;101. 对称二叉树&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你一个二叉树的根节点 &lt;code&gt;root&lt;/code&gt; ， 检查它是否轴对称。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pic.leetcode.cn/1698026966-JDYPDU-image.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,2,2,3,4,4,3]
输出：true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pic.leetcode.cn/1698027008-nPFLbM-image.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,2,2,null,3,null,3]
输出：false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目在范围 &lt;code&gt;[1, 1000]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你可以运用递归和迭代两种方法解决这个问题吗？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 因为树是递归结构，所以大多数题可以递归解。这题的解法，就是左右孩子，一个往左一个往右，一个往右一个往左，两种情况同时递归，取个and。
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root:TreeNode)-&amp;gt;bool:
    if not root:
        return True
    # 递归用check函数，看两边
    def check(left, right):
        if not left and not right:
            return True
        if not left or not right:
            return False
        if left.val != right.val:
            return False
        return check(left.left, right.right) and check(left.right, right.left)
    return check(root.left, root.right)

   

if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;第一次写懵逼，第二次写想看左侧左根右和右侧右根左是否一样，样例虽然能过，但是忽略了其实一个顺序是确定不了树的。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;被秒了。想到了是分别走，但是没想到用递归，递归的核心代码非常简洁，看清判断的条件，用and链接左左右右、左右右左即可。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树的直径&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;543. 二叉树的直径&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你一棵二叉树的根节点，返回该树的  &lt;strong&gt;直径&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;二叉树的  &lt;strong&gt;直径&lt;/strong&gt;  是指树中任意两个节点之间最长路径的  &lt;strong&gt;长度&lt;/strong&gt;  。这条路径可能经过也可能不经过根节点 &lt;code&gt;root&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;两节点之间路径的  &lt;strong&gt;长度&lt;/strong&gt;  由它们之间边数表示。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/03/06/diamtree.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,2,3,4,5]
输出：3
解释：3 ，取路径 [4,2,1,3] 或 [5,2,1,3] 的长度。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,2]
输出：1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目在范围 &lt;code&gt;[1, 10^4]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这一题递归对每个节点算左右字数深度，然后维持一个全局最大量和左右深度和对比。
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root: TreeNode) -&amp;gt; int:
    ans = 0

    def dfs(node: TreeNode) -&amp;gt; int:
        nonlocal ans
        if not node:
            return 0

        left_depth = dfs(node.left)
        right_depth = dfs(node.right)

        ans = max(ans, left_depth + right_depth)

        # 递归完返回的时候依次取比较深的一边+1
        return max(left_depth, right_depth) + 1

    dfs(root)
    return ans


if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题有一个dfs直接计算最大深度，可以仔细看一下写法。&lt;/li&gt;
&lt;li&gt;中间比较更新一个nonlocal量是常用的递归更新全局量。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;被秒了。其实就是求最大深度的代码，然后中间记录更新一下直径。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树的层序遍历&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;102. 二叉树的层序遍历&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你二叉树的根节点 &lt;code&gt;root&lt;/code&gt; ，返回其节点值的  &lt;strong&gt;层序遍历&lt;/strong&gt;  。 （即逐层地，从左到右访问所有节点）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/02/19/tree1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [3,9,20,null,null,15,7]
输出：[[3],[9,20],[15,7]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1]
输出：[[1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目在范围 &lt;code&gt;[0, 2000]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-1000 &amp;lt;= Node.val &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 1 · 队列法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 层序遍历非常基础，有很多种写法，我们先实现最基础的队列法
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root: TreeNode) -&amp;gt; int:
    ans = []
    if not root:
        return None
    q = deque([root])
    while q:
        sz = len(q)
        level = []
        for _ in range(sz):
            curr = q.popleft()
            level.append(curr.val)
            if curr.left:
                q.append(curr.left)
            if curr.right:
                q.append(curr.right)
        # 本层遍历结束
        ans.append(level)
    return ans

if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · dfs+depth&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 层序遍历的第二种方法，记录深度的dfs
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root: TreeNode) -&amp;gt; list[list[int]]:
    ans = []
    def dfs(node, depth):
        if not node:
            return
        # ans记录层数，如果depth与其相等，说明为新层（depth从0）
        if depth == len(ans):
            ans.append([])
        ans[depth].append(node.val)
        dfs(node.left, depth + 1)
        dfs(node.right, depth + 1)

    dfs(root, 0)
    return ans


if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 题解 3 · IDDFS&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 想兼顾 DFS 省空间 和 BFS 按层扩展特性的时候，可以用IDDFS（迭代加深搜索）。思路是每次加深能搜的最深深度，然后循环DFS。
# 常用在状态搜索、博弈搜索等情况
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root: TreeNode) -&amp;gt; list[list[int]]:
    if not root:
        return []

    ans = []
    # 带有目标层数限制的dfs，每次取一次level
    def collect(node, depth, target, level) -&amp;gt; None:
        if not node:
            return
        if depth == target:
            level.append(node.val)
            return
        collect(node.left, depth + 1, target, level)
        collect(node.right, depth + 1, target, level)

    target = 0
    while True:
        level = []
        collect(root, 0, target, level)
        # 循环到没东西的时候停止
        if not level:
            break
        ans.append(level)
        target += 1

    return ans



if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题我记得面试考过一次，不用队列BFS，所以就总结一下所有广搜的方法。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;6. 二刷&lt;/h2&gt;
&lt;p&gt;队列法随便秒。dfs+depth的方法有点忘了，再看看。核心在于用depth == len(ans)来判断新层。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;将有序数组转换为二叉搜索树&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;108. 将有序数组转换为二叉搜索树&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; ，其中元素已经按  &lt;strong&gt;升序&lt;/strong&gt;  排列，请你将其转换为一棵 平衡 二叉搜索树。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/02/18/btree1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [-10,-3,0,5,9]
输出：[0,-3,9,-10,null,5]
解释：[0,-10,5,null,-3,null,9] 也将被视为正确答案：
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/02/18/btree.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,3]
输出：[3,1]
解释：[1,null,3] 和 [3,1] 都是高度平衡二叉搜索树。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 按  &lt;strong&gt;严格递增&lt;/strong&gt;  顺序排列&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题不需要自己去构造ACL树，因为已经给了有序数组，用分治一半一半递归构造，天然就是平衡的
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 升级打印树，我们加入一个弹出None的同时也将None加入答案。然后，把尾巴多余的None弹出，得到结果。
def print_tree(root: TreeNode):
    if not root:
        print([])
        return

    ans = []
    q = deque([root])

    while q:
        node = q.popleft()
        if node is None:
            ans.append(None)
            continue
        ans.append(node.val)
        # 现在允许加入None
        q.append(node.left)
        q.append(node.right)

    while ans and ans[-1] is None:
        ans.pop()

    print(str(ans).replace(&quot;None&quot;, &quot;null&quot;))


# 我们给solution的定义是，返回平衡二叉树的根
def solution(nums:list)-&amp;gt;TreeNode:
    if not nums:
        return None
    mid = len(nums)//2
    val = nums[mid]
    root = TreeNode(val)
    root.left = solution(nums[:mid])
    root.right = solution(nums[mid+1:])
    return root


if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print_tree(solution(nums))
    
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;一开始以为要写ACL树，吓死我了。这一题主要注意两点，一个是两半递归的时候，切片的位置要避开root，然后mid从长度或者从长度-1开始切都是对的，正好得到的就是示例两种答案（偏左和偏右）。&lt;/li&gt;
&lt;li&gt;另一点是这题需要打印空，升级了一下print，具体写法是不在入队前检查None，在加ans时检查None。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;果然一刷没亲自写出来的题印象就不深，又没写出来。思路其实很简单，就是不断拉出来重点当root就行了。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;验证二叉搜索树&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;98. 验证二叉搜索树&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个二叉树的根节点 &lt;code&gt;root&lt;/code&gt; ，判断其是否是一个有效的二叉搜索树。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;有效&lt;/strong&gt;  二叉搜索树定义如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;节点的左子树只包含  &lt;strong&gt;严格小于&lt;/strong&gt;  当前节点的数。&lt;/li&gt;
&lt;li&gt;节点的右子树只包含  &lt;strong&gt;严格大于&lt;/strong&gt;  当前节点的数。&lt;/li&gt;
&lt;li&gt;所有左子树和右子树自身必须也是二叉搜索树。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/12/01/tree1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [2,1,3]
输出：true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/12/01/tree2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [5,1,4,null,null,3,6]
输出：false
解释：根节点的值是 5 ，但是右子节点的值是 4 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目范围在&lt;code&gt;[1, 10^4]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-2^31 &amp;lt;= Node.val &amp;lt;= 2^31 - 1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 最标准的递归是上下界解法，因为BST要求的约束不仅在能看到的层，但看局部约束不行来递归是不行的。
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root:TreeNode)-&amp;gt;bool:
    def dfs(node,low,high):
        if not node:
            return True
        if not (low&amp;lt;node.val&amp;lt;high):
            return False
        # 更新约束
        return dfs(node.left,low,node.val) and dfs(node.right,node.val,high)
    return dfs(root,float(&apos;-inf&apos;),float(&apos;inf&apos;))

if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题易错点是容易只考虑当前父子直接递归，BST的约束是跨越整个树的，这样会漏掉跨层约束。&lt;/li&gt;
&lt;li&gt;真正的做法是一直维持上下界，往左走要更新上界（因为左边都要小），往右走更新上界，遍历一遍全部判断。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;h2&gt;算是弄出来了，但是记得判空永远在最前面，因为要访问val。&lt;/h2&gt;
&lt;h1&gt;二叉搜索树中第 K 小的元素&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;230. 二叉搜索树中第 K 小的元素&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个二叉搜索树的根节点 &lt;code&gt;root&lt;/code&gt; ，和一个整数 &lt;code&gt;k&lt;/code&gt; ，请你设计一个算法查找其中第 &lt;code&gt;k&lt;/code&gt; 小的元素（&lt;code&gt;k&lt;/code&gt; 从 1 开始计数）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/01/28/kthtree1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [3,1,4,null,2], k = 1
输出：1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/01/28/kthtree2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [5,3,6,2,4,null,null,1], k = 3
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中的节点数为 &lt;code&gt;n&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= k &amp;lt;= n &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= Node.val &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 如果二叉搜索树经常被修改（插入/删除操作）并且你需要频繁地查找第 &lt;code&gt;k&lt;/code&gt; 小的值，你将如何优化算法？&lt;/p&gt;
&lt;h2&gt;2. 题解 1&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 还记得链表第k小的元素么，这里是二叉搜索树，中序就是有序排列。可以中序出数组，直接访问，这是最容易想到的。

import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root:TreeNode,k:int)-&amp;gt;int:
    ans = []
    def dfs(node):
        if not node:
            return None
        dfs(node.left)
        ans.append(node.val)
        dfs(node.right)
    dfs(root)
    return ans[k-1]

if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    k = int(input().strip())
    root = build_tree(data)
    print(solution(root,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 进阶&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 进阶操作：如果二叉搜索树经常被修改（插入/删除操作）并且你需要频繁地查找第 k 小的值，你将如何优化算法？
# 这个要求我们就不能打出表了，因为二叉树时刻变动，重复打表很麻烦。
# 这题的标准做法是添加一个属性

import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root: TreeNode, k: int) -&amp;gt; int:
    # 给节点动态加属性
    # 递归方式，给每个节点加一个维持子树大小属性
    def build_size(node: TreeNode) -&amp;gt; int:
        if not node:
            return 0
        left_size = build_size(node.left)
        right_size = build_size(node.right)
        node.size = left_size + right_size + 1
        return node.size

    # 有了子树大小这个属性，就可以一直看左边的size来遍历，如果左边找k-1
    # 注意切换到右边寻找的时候，要给左侧全部剪掉（因为左侧都比右侧小），去找k-left_size-1
    def kth(node: TreeNode, k: int) -&amp;gt; int:
        left_size = node.left.size if node.left else 0

        if k == left_size + 1:
            return node.val
        if k &amp;lt;= left_size:
            return kth(node.left, k)
        return kth(node.right, k - left_size - 1)

    build_size(root)
    return kth(root, k)


if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    k = int(input().strip())
    root = build_tree(data)
    print(solution(root,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;进阶方法运用了python动态绑定属性的方法，很巧妙。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;这第二种方法也太哈人了，代码不长，但是逻辑好绕啊。我们先要走一遍，给每个节点多维持一个子树大小属性。注意，这里的size的含义，是以当前node为根的子树的size，当然要+1包含自身。然后，我们去找左边size为k-1的，如果左边不够k-1了，直接去右边找k-1-left_size，这就是核心代码。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树的右视图&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;199. 二叉树的右视图&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个二叉树的  &lt;strong&gt;根节点&lt;/strong&gt;  &lt;code&gt;root&lt;/code&gt;，想象自己站在它的右侧，按照从顶部到底部的顺序，返回从右侧所能看到的节点值。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**root = [1,2,3,null,5,null,4]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; [1,3,4]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解释：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2024/11/24/tmpd5jn43fs-1.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**root = [1,2,3,4,null,null,null,5]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt;[1,3,4,5]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解释：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2024/11/24/tmpkpe40xeh-1.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入：&lt;/strong&gt; root = [1,null,3]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; [1,3]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 4：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**root = []&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; []&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;二叉树的节点个数的范围是 &lt;code&gt;[0,100]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 最容易想到的就是层序遍历，然后每次让找到当前层的最右侧
import ast
from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印树
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        ans.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    print(ans)


def solution(root:TreeNode)-&amp;gt;list:
    ans = []
    if not root:
        return []
    q = deque([root])
    while q:
        sz = len(q)
        ans.append(q[-1].val)
        for _ in range(sz):
            curr = q.popleft()
            if curr.left:
                q.append(curr.left)
            if curr.right:
                q.append(curr.right)
    return ans

if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 二刷&lt;/h2&gt;
&lt;p&gt;层序遍历看q[-1]即可&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树展开为链表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;114. 二叉树展开为链表&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你二叉树的根结点 &lt;code&gt;root&lt;/code&gt; ，请你将它展开为一个单链表：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;展开后的单链表应该同样使用 &lt;code&gt;TreeNode&lt;/code&gt; ，其中 &lt;code&gt;right&lt;/code&gt; 子指针指向链表中下一个结点，而左子指针始终为 &lt;code&gt;null&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;展开后的单链表应该与二叉树 &lt;a href=&quot;https://baike.baidu.com/item/%E5%85%88%E5%BA%8F%E9%81%8D%E5%8E%86/6442839?fr=aladdin&quot;&gt; &lt;strong&gt;先序遍历&lt;/strong&gt; &lt;/a&gt; 顺序相同。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/01/14/flaten.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,2,5,3,4,null,6]
输出：[1,null,2,null,3,null,4,null,5,null,6]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = []
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [0]
输出：[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中结点数在范围 &lt;code&gt;[0, 2000]&lt;/code&gt; 内&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-100 &amp;lt;= Node.val &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你可以使用原地算法（&lt;code&gt;O(1)&lt;/code&gt; 额外空间）展开这棵树吗？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 先来一般解法，直接按结构新建链表
import ast
from collections import deque

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印加强
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        if not node:
            ans.append(None)
            continue
        ans.append(node.val)
        q.append(node.left)
        q.append(node.right)
        
    while ans and ans[-1] is None:
        ans.pop()

    print(str(ans).replace(&quot;None&quot;, &quot;null&quot;))


def solution(root:TreeNode)-&amp;gt;TreeNode:
    if not root:
        return None
    dummy = TreeNode()
    dummy.right = root
    p = dummy
    def dfs(node):
        nonlocal p
        if not node:
            return None
        # 先序穿针
        p.right = TreeNode(node.val)
        p = p.right
        dfs(node.left)
        dfs(node.right)
    dfs(root)
    return dummy.right
    


if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print_tree(solution(root))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 · 进阶&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 进阶要求是O(1)就地展开，做法就是将每个节点的左子树都接到右边。
# 具体来说，如果节点有左子树，先找左子树最右节点，然后把原右子树接到这个最右节点后面，最后左子树整体移动到右边，左边清空。可以脑内模拟一下这个过程。
import ast
from collections import deque

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印加强
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        if not node:
            ans.append(None)
            continue
        ans.append(node.val)
        q.append(node.left)
        q.append(node.right)
        
    while ans and ans[-1] is None:
        ans.pop()

    print(str(ans).replace(&quot;None&quot;, &quot;null&quot;))


def solution(root: TreeNode) -&amp;gt; TreeNode:
    p = root
    while p:
        if p.left:
            # 找到左子树最右边的节点
            pre = p.left
            while pre.right:
                pre = pre.right

            # 原来的右子树接到左子树最右节点后面
            pre.right = p.right

            # 左子树挪到右边
            p.right = p.left
            p.left = None
        # 然后一直沿着right走
        p = p.right

    return root

    


if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print_tree(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;第二种原地结果比较难想，多看看板子。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;被秒杀，重新看板子，说实话非常绕。。&lt;/p&gt;
&lt;h2&gt;6. 题解2&lt;/h2&gt;
&lt;p&gt;本题实际上还有一种非常通俗容易解决的方案，我们会发现展开后的顺序实际上就是先序遍历的顺序，那么我们可以反着来（右左根），逆着先序将每个节点接到上一个的前面，最终就可以完成。不过这种解法也是空间O(h)就是了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode)-&amp;gt;ListNode:
    prev = None
    def dfs(node):
        nonlocal prev
        if not node:
            return
        dfs(node.right)
        dfs(node.left)
        node.right = prev
        node.left = None
        prev = node
    dfs(root)
    return root   
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;从前序与中序遍历序列构造二叉树&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;105. 从前序与中序遍历序列构造二叉树&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定两个整数数组 &lt;code&gt;preorder&lt;/code&gt; 和 &lt;code&gt;inorder&lt;/code&gt; ，其中 &lt;code&gt;preorder&lt;/code&gt; 是二叉树的 &lt;strong&gt;先序遍历&lt;/strong&gt; ， &lt;code&gt;inorder&lt;/code&gt; 是同一棵树的 &lt;strong&gt;中序遍历&lt;/strong&gt; ，请构造二叉树并返回其根节点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/02/19/tree.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出: [3,9,20,null,null,15,7]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: preorder = [-1], inorder = [-1]
输出: [-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= preorder.length &amp;lt;= 3000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;inorder.length == preorder.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-3000 &amp;lt;= preorder[i], inorder[i] &amp;lt;= 3000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preorder&lt;/code&gt; 和 &lt;code&gt;inorder&lt;/code&gt; 均  &lt;strong&gt;无重复&lt;/strong&gt;  元素&lt;/li&gt;
&lt;li&gt;&lt;code&gt;inorder&lt;/code&gt; 均出现在 &lt;code&gt;preorder&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preorder&lt;/code&gt;  &lt;strong&gt;保证&lt;/strong&gt;  为二叉树的前序遍历序列&lt;/li&gt;
&lt;li&gt;&lt;code&gt;inorder&lt;/code&gt;  &lt;strong&gt;保证&lt;/strong&gt;  为二叉树的中序遍历序列&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 从前序和中序构建二叉树，用手来做比较简单，写代码容易绕进去。
# 方法是将preorder作为根的列表，切分成left和right，然后递归下去，传进去新的两半数组和根。

from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 打印升级
def print_tree(root:TreeNode):
    # 也就是层序遍历
    ans = []
    if not root:
        print([])
        return
    q = deque([root])
    while q:
        node = q.popleft()
        if not node:
            ans.append(None)
            continue
        ans.append(node.val)
        q.append(node.left)
        q.append(node.right)

    while ans[-1] == None:
        ans.pop()

    result = str(ans).replace(&quot;None&quot;,&quot;null&quot;)

    print(result)


def solution(preorder:list,inorder:list) -&amp;gt; TreeNode:
    if not preorder or not inorder:
        return None
    # 1. 定位根节点
    root_val = preorder[0]
    root = TreeNode(root_val)
    # 2. 定位分界线
    mid_idx = inorder.index(root_val)
    # 3. 切片挂子树
    # 注意计算一下左右子树的长度
    root.left = solution(
        preorder[1:1+mid_idx],
        inorder[:mid_idx]
    )
    root.right = solution(
        preorder[1+mid_idx:],
        inorder[mid_idx+1:]
    )
    return root

if __name__ == &quot;__main__&quot;:
    preorder = list(map(int,input().strip().split(&apos;,&apos;)))
    inorder = list(map(int,input().strip().split(&apos;,&apos;)))
    print_tree(solution(preorder,inorder))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题的难点在切片的位置判断，需要多练习。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;大体想到了解决方式，就是preorder的下标处理还是有点问题。中左右切分的时候，左的长度已经用idx给出，其实可以直接在示例数组上自己切一下看看。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;路径总和 III&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;437. 路径总和 III&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个二叉树的根节点 &lt;code&gt;root&lt;/code&gt; ，和一个整数 &lt;code&gt;targetSum&lt;/code&gt; ，求该二叉树里节点值之和等于 &lt;code&gt;targetSum&lt;/code&gt; 的  &lt;strong&gt;路径&lt;/strong&gt;  的数目。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;路径&lt;/strong&gt;  不需要从根节点开始，也不需要在叶子节点结束，但是路径方向必须是向下的（只能从父节点到子节点）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/04/09/pathsum3-1-tree.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
输出：3
解释：和等于 8 的路径有 3 条，如图所示。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;二叉树的节点个数的范围是 &lt;code&gt;[0,1000]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= Node.val &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-1000 &amp;lt;= targetSum &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 直接朴素递归，一句话，每个节点都试一遍
# 直接朴素递归，一句话，每个节点都试一遍

import ast

from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root


def solution(root: TreeNode, targetSum: int) -&amp;gt; int:
    # 统计从当前节点出发的合法路径数
    def count_from(node: TreeNode, target: int) -&amp;gt; int:
        if not node:
            return 0

        # 只包含节点自己
        count = 1 if node.val == target else 0
        # 当前节点 + 左边继续往下
        count += count_from(node.left, target - node.val)
        # 当前节点 + 右边继续往下
        count += count_from(node.right, target - node.val)
        return count

    if not root:
        return 0

    # solution是统计整棵树的，等于当前根出发的+左右子树的
    return (
        count_from(root, targetSum)
        + solution(root.left, targetSum)
        + solution(root.right, targetSum)
    )



if __name__ == &quot;__main__&quot;:
    data = input().strip().replace(&quot;null&quot;,&quot;None&quot;)
    targetSum = int(input().strip())
    nums = ast.literal_eval(data)
    root = build_tree(nums)
    print(solution(root,targetSum))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题好难，得用两个递归。首先是找从这个节点开始，递归找有没有路径。然后递归每个节点加上当前和左右的路径和。能自己写出这题的递归已经对树的递归非常熟练了。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;p&gt;被秒了，对于这种，起点可以是根也可以不是的，大部分需要用到递归式子运算。比如这题的返回count_from(root, targetSum)+ solution(root.left, targetSum)+ solution(root.right, targetSum)，这里count_from仅表示以xx为起点的有多少路径符合条件，而solution本身是整个树有多少符合的（即count_all），所以这是两层递归在一起。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树的最近公共祖先&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;236. 二叉树的最近公共祖先&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://baike.baidu.com/item/%E6%9C%80%E8%BF%91%E5%85%AC%E5%85%B1%E7%A5%96%E5%85%88/8918834?fr=aladdin&quot;&gt;百度百科&lt;/a&gt;中最近公共祖先的定义为：“对于有根树 T 的两个节点 p、q，最近公共祖先表示为一个节点 x，满足 x 是 p、q 的祖先且 x 的深度尽可能大（ &lt;strong&gt;一个节点也可以是它自己的祖先&lt;/strong&gt; ）。”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2018/12/14/binarytree.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出：3
解释：节点 5 和节点 1 的最近公共祖先是节点 3 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2018/12/14/binarytree.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出：5
解释：节点 5 和节点 4 的最近公共祖先是节点 5 。因为根据定义最近公共祖先节点可以为节点本身。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,2], p = 1, q = 2
输出：1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目在范围 &lt;code&gt;[2, 10^5]&lt;/code&gt; 内。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= Node.val &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;所有 &lt;code&gt;Node.val&lt;/code&gt; &lt;code&gt;互不相同&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;p != q&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;p&lt;/code&gt; 和 &lt;code&gt;q&lt;/code&gt; 均存在于给定的二叉树中。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 · parent属性&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 我们给节点添加parent属性，然后让标记的p、q同时往上，找到空之后再从头开始。按照相交链表的结论，最后一定会在交点（共同祖先）相遇，如果没有共同祖先，也会在None相遇。
import ast

from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root


def solution(root: TreeNode, p: int, q: int) -&amp;gt; TreeNode:
    # 指针思维
    node_p = None
    node_q = None
    def build_parent(node: TreeNode, parent: TreeNode):
        nonlocal node_p, node_q
        if not node:
            return
        node.parent = parent
        # 顺便找到p、q
        if node.val == p:
            node_p = node
        if node.val == q:
            node_q = node
        build_parent(node.left, node)
        build_parent(node.right, node)
    build_parent(root, None)
    a, b = node_p, node_q
    while a != b:
        a = a.parent if a else node_q
        b = b.parent if b else node_p
    return a

    
if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    p = int(input().strip())
    q = int(input().strip())
    root = build_tree(data)
    ans = solution(root, p, q)
    print(ans.val)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 递归&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 如果用树的思维来做，就要先想到从递归写。这题的做法是经典的LCA算法，请熟练掌握这个。
import ast

from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root

# 我们用solution(root,p,q)表示以root为根的这颗子树中，p、q的最近公共祖先
def solution(root: TreeNode, p: TreeNode, q: TreeNode) -&amp;gt; TreeNode:
    # 找到目标节点或者找到空了先往上交
    if not root or root == p or root == q:
        return root
    # 递归左右子树
    left = solution(root.left, p, q)
    right = solution(root.right, p, q)
    # 如果左右都返回了非空值，也就是都找到了，那root就是第一次汇合的地方
    if left and right:
        return root
    # 如果只有一边非空，则将自己上交，继续往上找祖先
    return left if left else right


    
if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    p = int(input().strip())
    q = int(input().strip())
    root = build_tree(data)
    ans = solution(root, p, q)
    print(ans.val)
    
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题第二种LCA算法是最优雅的解法，请记住板子。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 二刷&lt;/h2&gt;
&lt;p&gt;还是没想到递归方法，但是LCA算法的确看起来优雅。几个要点，solution表示子树中p、q的最近公共祖先，而出口是找到p、q或者为空，上交left和right两个信息，只有left和right都非空（也就是左右两边找到了），由于是从底向上的，所以就是第一个汇合的地方。如果只有一遍是空的，那应该将非空返回上去继续找。（由于还要满足祖先深度尽可能大，所以找到root继续上交）&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二叉树中的最大路径和&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;124. 二叉树中的最大路径和&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;二叉树中的  &lt;strong&gt;路径&lt;/strong&gt;  被定义为一条节点序列，序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中  &lt;strong&gt;至多出现一次&lt;/strong&gt;  。该路径  &lt;strong&gt;至少包含一个&lt;/strong&gt;  节点，且不一定经过根节点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;路径和&lt;/strong&gt;  是路径中各节点值的总和。&lt;/p&gt;
&lt;p&gt;给你一个二叉树的根节点 &lt;code&gt;root&lt;/code&gt; ，返回其  &lt;strong&gt;最大路径和&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/13/exx1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [1,2,3]
输出：6
解释：最优路径是 2 -&amp;gt; 1 -&amp;gt; 3 ，路径和为 2 + 1 + 3 = 6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/13/exx2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：root = [-10,9,20,null,null,15,7]
输出：42
解释：最优路径是 15 -&amp;gt; 20 -&amp;gt; 7 ，路径和为 15 + 20 + 7 = 42
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;树中节点数目范围是 &lt;code&gt;[1, 3 * 10^4]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-1000 &amp;lt;= Node.val &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题根二叉树直径的题有点像，不确定起点，不确定是否经过根。
import ast

from collections import deque
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 从列表创建树
def build_tree(data:list):
    # 空返回
    if not data or data[0] is None:
        return None
    
    root = TreeNode(data[0])
    q = deque([root])
    # 除去根，从1开始
    i = 1

    # 正常的列表其实是层序遍历顺序给出，所有用层序来建树即可，维持一个i随时监控有没有超过长度。
    while q and i&amp;lt;len(data):
        node = q.popleft()

        if i &amp;lt;len(data) and data[i] is not None:
            node.left = TreeNode(data[i])
            q.append(node.left)
        i += 1

        if i&amp;lt;len(data) and data[i] is not None:
            node.right = TreeNode(data[i])
            q.append(node.right)
        i += 1
    return root


def solution(root: TreeNode) -&amp;gt; int:
    ans = float(&quot;-inf&quot;)
    # dfs记录当前节点往下走的一条最大贡献路径
    # ans记录当前节点作为拐点时的最大路径和
    def dfs(node: TreeNode) -&amp;gt; int:
        nonlocal ans
        if not node:
            return 0

        # 依旧变形递归，值可能有负数，跟0做一下max
        left_gain = max(dfs(node.left), 0)
        right_gain = max(dfs(node.right), 0)

        # 最大路径和可能正好在当前节点这里左右连起来
        ans = max(ans, node.val + left_gain + right_gain)
        return node.val + max(left_gain, right_gain)

    dfs(root)
    return ans

  
if __name__ == &quot;__main__&quot;:
    data = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    root = build_tree(data)
    print(solution(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;我草，好难，暂时不看，回头理解背板&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;本题结合二叉树直径一起看！&lt;/p&gt;
&lt;h2&gt;5. 拓展&lt;/h2&gt;
&lt;p&gt;本题和二叉树直径题，都是“答案都可能在某个节点这里，把左边一条链 + 当前节点 + 右边一条链 拼起来”的题目，每个节点都尝试作为“拐点”结算一次答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;left = dfs(node.left)
right = dfs(node.right)

ans = max(ans, 某种 left + right 的组合)
return 某种只能选一边的值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;return必须写成单链状态来返回父节点，因为父节点只能接左边或右边的一条。而更新答案的时候，两边都加。
并排记忆如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;二叉树直径&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if not node:
        return 0
    left = dfs(node.left)
    right = dfs(node.right)
    ans = max(ans, left + right)
    return max(left, right) + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;二叉树最大路径和&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if not node:
        return 0
    left = max(dfs(node.left), 0)
    right = max(dfs(node.right), 0)
    ans = max(ans, node.val + left + right)
    return node.val + max(left, right)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;岛屿数量&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;200. 岛屿数量&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个由 &lt;code&gt;&apos;1&apos;&lt;/code&gt;（陆地）和 &lt;code&gt;&apos;0&apos;&lt;/code&gt;（水）组成的的二维网格，请你计算网格中岛屿的数量。&lt;/p&gt;
&lt;p&gt;岛屿总是被水包围，并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。&lt;/p&gt;
&lt;p&gt;此外，你可以假设该网格的四条边均被水包围。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：grid = [
  [&apos;1&apos;,&apos;1&apos;,&apos;1&apos;,&apos;1&apos;,&apos;0&apos;],
  [&apos;1&apos;,&apos;1&apos;,&apos;0&apos;,&apos;1&apos;,&apos;0&apos;],
  [&apos;1&apos;,&apos;1&apos;,&apos;0&apos;,&apos;0&apos;,&apos;0&apos;],
  [&apos;0&apos;,&apos;0&apos;,&apos;0&apos;,&apos;0&apos;,&apos;0&apos;]
]
输出：1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：grid = [
  [&apos;1&apos;,&apos;1&apos;,&apos;0&apos;,&apos;0&apos;,&apos;0&apos;],
  [&apos;1&apos;,&apos;1&apos;,&apos;0&apos;,&apos;0&apos;,&apos;0&apos;],
  [&apos;0&apos;,&apos;0&apos;,&apos;1&apos;,&apos;0&apos;,&apos;0&apos;],
  [&apos;0&apos;,&apos;0&apos;,&apos;0&apos;,&apos;1&apos;,&apos;1&apos;]
]
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == grid.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == grid[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 300&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grid[i][j]&lt;/code&gt; 的值为 &lt;code&gt;&apos;0&apos;&lt;/code&gt; 或 &lt;code&gt;&apos;1&apos;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 虽然是图论的第一题，但是和建图没关系，可以直接用输入的矩阵当图本身。
# dfs本身可以用来找连通图的个数，只要用外面循环调用dfs就行。这题思路很简单。
import ast


def solution(grid: list[list[str]]) -&amp;gt; int:
    if not grid:
        return 0

    m, n = len(grid), len(grid[0])
    ans = 0

    def dfs(i: int, j: int) -&amp;gt; None:
        # 走到水里或越界
        if i &amp;lt; 0 or i &amp;gt;= m or j &amp;lt; 0 or j &amp;gt;= n or grid[i][j] != &quot;1&quot;:
            return

        # dfs各种走四个方向
        grid[i][j] = &quot;0&quot;
        dfs(i - 1, j)
        dfs(i + 1, j)
        dfs(i, j - 1)
        dfs(i, j + 1)

    # 找到一块陆地，就dfs把附近的路都水淹掉（标记为0）
    for i in range(m):
        for j in range(n):
            if grid[i][j] == &quot;1&quot;:
                ans += 1
                dfs(i, j)

    return ans


if __name__ == &quot;__main__&quot;:
    grid = ast.literal_eval(input().strip())
    print(solution(grid))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;dfs在图里的经典用法&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;二刷中，我把dfs写成了这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(grid,i,j):
    if grid[i][j] == &apos;1&apos;:
        grid[i][j] = &apos;0&apos;
    elif grid[i][j] == &apos;0&apos;:
        return
    if i-1&amp;gt;=0:
        dfs(grid,i-1,j)
    if i+1&amp;lt;len(grid):
        dfs(grid,i+1,j)
    if j+1&amp;lt;len(grid[0]):
        dfs(grid,i,j+1)
    if j-1&amp;gt;=0:
        dfs(grid,i,j-1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AI说，这不是稳妥的写法，大多数人写dfs的时候，会首先判断是否合法，然后再走四个方向，就是按照第一次的题解一样，在入口判断是否有越界或者等于&apos;1&apos;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;腐烂的橘子&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;994. 腐烂的橘子&lt;/h4&gt;
&lt;p&gt;难度：1433&lt;/p&gt;
&lt;p&gt;在给定的 &lt;code&gt;m x n&lt;/code&gt; 网格 &lt;code&gt;grid&lt;/code&gt; 中，每个单元格可以有以下三个值之一：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;值 &lt;code&gt;0&lt;/code&gt; 代表空单元格；&lt;/li&gt;
&lt;li&gt;值 &lt;code&gt;1&lt;/code&gt; 代表新鲜橘子；&lt;/li&gt;
&lt;li&gt;值 &lt;code&gt;2&lt;/code&gt; 代表腐烂的橘子。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每分钟，腐烂的橘子  &lt;strong&gt;周围 4 个方向上相邻&lt;/strong&gt;  的新鲜橘子都会腐烂。&lt;/p&gt;
&lt;p&gt;返回 &lt;em&gt;直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能，返回 &lt;code&gt;-1&lt;/code&gt;&lt;/em&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2019/02/16/oranges.png&quot; alt=&quot;&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：grid = [[2,1,1],[1,1,0],[0,1,1]]
输出：4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：grid = [[2,1,1],[0,1,1],[1,0,1]]
输出：-1
解释：左下角的橘子（第 2 行， 第 0 列）永远不会腐烂，因为腐烂只会发生在 4 个方向上。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：grid = [[0,2]]
输出：0
解释：因为 0 分钟时已经没有新鲜橘子了，所以答案就是 0 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == grid.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == grid[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grid[i][j]&lt;/code&gt; 仅为 &lt;code&gt;0&lt;/code&gt;、&lt;code&gt;1&lt;/code&gt; 或 &lt;code&gt;2&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 用手画出来这个过程倒是很简单，二重遍历+更新time编，就是有点担心会不会太慢了
import ast
from collections import deque

def solution(grid: list[list[int]]) -&amp;gt; int:
    m, n = len(grid), len(grid[0])
    q = deque()
    fresh = 0

    # 先把所有腐烂橘子入队，同时统计新鲜橘子数量
    for i in range(m):
        for j in range(n):
            if grid[i][j] == 2:
                q.append((i, j))
            elif grid[i][j] == 1:
                fresh += 1

    minutes = 0
    # 预定义四个方向
    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

    while q and fresh &amp;gt; 0:
        for _ in range(len(q)):
            x, y = q.popleft()
            for dx, dy in directions:
                nx, ny = x + dx, y + dy
                # python判断坐标合法的方法
                if 0 &amp;lt;= nx &amp;lt; m and 0 &amp;lt;= ny &amp;lt; n and grid[nx][ny] == 1:
                    grid[nx][ny] = 2
                    fresh -= 1
                    q.append((nx, ny))
        minutes += 1

    return minutes if fresh == 0 else -1


    
if __name__ == &quot;__main__&quot;:
    grid = ast.literal_eval(input().strip())
    print(solution(grid))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这种题看起来思路简单，但是比较考验你的写法和边界条件，建议平时多敲练习。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;想到的方法solution解决每轮感染，返回是否有修改的flag。如果没修改且不存在新鲜橘子就结束了，这种方式修改需要先遍历单独标记，然后再统一腐烂。&lt;/p&gt;
&lt;p&gt;其实题解的方法还是比较好的，这是一种多源头bfs的解法，很直观且容易理解（因为橘子每次最多感染一层），对比之前的岛屿问题，则是多源dfs。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;课程表&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;207. 课程表&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;你这个学期必须选修 &lt;code&gt;numCourses&lt;/code&gt; 门课程，记为 &lt;code&gt;0&lt;/code&gt; 到 &lt;code&gt;numCourses - 1&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;在选修某些课程之前需要一些先修课程。 先修课程按数组 &lt;code&gt;prerequisites&lt;/code&gt; 给出，其中 &lt;code&gt;prerequisites[i] = [ai, bi]&lt;/code&gt; ，表示如果要学习课程 &lt;code&gt;ai&lt;/code&gt; 则  &lt;strong&gt;必须&lt;/strong&gt;  先学习课程  &lt;code&gt;bi&lt;/code&gt; 。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;例如，先修课程对 &lt;code&gt;[0, 1]&lt;/code&gt; 表示：想要学习课程 &lt;code&gt;0&lt;/code&gt; ，你需要先完成课程 &lt;code&gt;1&lt;/code&gt; 。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;请你判断是否可能完成所有课程的学习？如果可以，返回 &lt;code&gt;true&lt;/code&gt; ；否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：numCourses = 2, prerequisites = [[1,0]]
输出：true
解释：总共有 2 门课程。学习课程 1 之前，你需要完成课程 0 。这是可能的。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：numCourses = 2, prerequisites = [[1,0],[0,1]]
输出：false
解释：总共有 2 门课程。学习课程 1 之前，你需要先完成​课程 0 ；并且学习课程 0 之前，你还应先完成课程 1 。这是不可能的。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= numCourses &amp;lt;= 2000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= prerequisites.length &amp;lt;= 5000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prerequisites[i].length == 2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= ai, bi &amp;lt; numCourses&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prerequisites[i]&lt;/code&gt; 中的所有课程对  &lt;strong&gt;互不相同&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这一题是经典的有向图判环，我们用队列实现拓扑排序
import ast
from collections import deque

def solution(numCourses: int, prerequisites: list[list[int]]) -&amp;gt; bool:
    # 简历邻接表
    graph = [[] for _ in range(numCourses)]
    indegree = [0] * numCourses

    # 建图：b -&amp;gt; a，表示学a之前要先学b
    for a, b in prerequisites:
        graph[b].append(a)
        # 维持一个入度，给后面拓扑排序用
        indegree[a] += 1

    # 开始拓扑排序，先将入度为0加入，然后一层一层剥
    q = deque()
    for i in range(numCourses):
        if indegree[i] == 0:
            q.append(i)

    count = 0
    while q:
        course = q.popleft()
        count += 1

        # 如果去掉孩子的入度全减1，如果变成入度0就入队
        for nxt in graph[course]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                q.append(nxt)
    # 如果所有数都出去了，说明无环
    return count == numCourses

if __name__ == &quot;__main__&quot;:
    numCourses = int(input().strip())
    prerequisites = ast.literal_eval(input().strip())
    print(solution(numCourses,prerequisites))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;拓扑排序给有向图判环是经典算法。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;有点忘了建图的语句了，邻接表建图法可以多复习一下。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;实现 Trie (前缀树)&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;208. 实现 Trie (前缀树)&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://baike.baidu.com/item/%E5%AD%97%E5%85%B8%E6%A0%91/9825209?fr=aladdin&quot;&gt;Trie&lt;/a&gt;&lt;/strong&gt; （发音类似 &quot;try&quot;）或者说  &lt;strong&gt;前缀树&lt;/strong&gt;  是一种树形数据结构，用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景，例如自动补全和拼写检查。&lt;/p&gt;
&lt;p&gt;请你实现 Trie 类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Trie()&lt;/code&gt; 初始化前缀树对象。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;void insert(String word)&lt;/code&gt; 向前缀树中插入字符串 &lt;code&gt;word&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;boolean search(String word)&lt;/code&gt; 如果字符串 &lt;code&gt;word&lt;/code&gt; 在前缀树中，返回 &lt;code&gt;true&lt;/code&gt;（即，在检索之前已经插入）；否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;boolean startsWith(String prefix)&lt;/code&gt; 如果之前已经插入的字符串 &lt;code&gt;word&lt;/code&gt; 的前缀之一为 &lt;code&gt;prefix&lt;/code&gt; ，返回 &lt;code&gt;true&lt;/code&gt; ；否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入
[&quot;Trie&quot;, &quot;insert&quot;, &quot;search&quot;, &quot;search&quot;, &quot;startsWith&quot;, &quot;insert&quot;, &quot;search&quot;]
[[], [&quot;apple&quot;], [&quot;apple&quot;], [&quot;app&quot;], [&quot;app&quot;], [&quot;app&quot;], [&quot;app&quot;]]
输出
[null, null, true, false, true, null, true]

解释
Trie trie = new Trie();
trie.insert(&quot;apple&quot;);
trie.search(&quot;apple&quot;);   // 返回 True
trie.search(&quot;app&quot;);     // 返回 False
trie.startsWith(&quot;app&quot;); // 返回 True
trie.insert(&quot;app&quot;);
trie.search(&quot;app&quot;);     // 返回 True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= word.length, prefix.length &amp;lt;= 2000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;word&lt;/code&gt; 和 &lt;code&gt;prefix&lt;/code&gt; 仅由小写英文字母组成&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insert&lt;/code&gt;、&lt;code&gt;search&lt;/code&gt; 和 &lt;code&gt;startsWith&lt;/code&gt; 调用次数  &lt;strong&gt;总计&lt;/strong&gt;  不超过 &lt;code&gt;3 * 10^4&lt;/code&gt; 次&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# Trie树是一种空间换时间极致的结构
import ast


class Trie:
    # 内部类：定义 Trie 树的每一个最小单元（节点）
    class TrieNode:
        def __init__(self):
            # 因为题目说只有小写英文字母，所以固定开 26 个分叉的数组即可。
            # 下标 0 代表 &apos;a&apos;，下标 25 代表 &apos;z&apos;
            self.children = [None] * 26
            # 核心灵魂：标记从根节点一路顺着树枝走到这里，是不是构成了一个完整的单词
            self.is_end = False

    def __init__(self):
        self.root = self.TrieNode()

    def insert(self, word: str) -&amp;gt; None:
        # 指针 p 从树根出发，准备顺着树干往下爬
        p = self.root
        for char in word:
            # 计算当前字母应该进 26 个分叉里的哪一个通道
            idx = ord(char) - ord(&apos;a&apos;)
            # 如果通往这个字母的“通道（树枝）”还不存在，就现搭一根树枝（新建节点）
            if not p.children[idx]:
                p.children[idx] = self.TrieNode()
            # 顺着这条刚搭好、或者早就有的树枝，大步往下走一层！
            p = p.children[idx]
        
        # 单词全部走完了（此时 p 停在这个单词的最后一个字母上）
        # 插上一面旗子：宣告这里是一个合法单词的终点！
        p.is_end = True

    def search(self, word: str) -&amp;gt; bool:
        p = self.root
        for char in word:
            idx = ord(char) - ord(&apos;a&apos;)
            # 如果走着走着发现没路了（通道是 None），说明字典里压根没存过这串字母，果断返回 False
            if not p.children[idx]:
                return False
            p = p.children[idx]
            
        # 走到底了，路是通的。但它到底是用我的这串字母结尾的单词，还是只是一部分前缀？
        # 全靠我们当时插下的那面旗帜 `is_end` 说了算！
        return p.is_end

    def startsWith(self, prefix: str) -&amp;gt; bool:
        p = self.root
        for char in prefix:
            idx = ord(char) - ord(&apos;a&apos;)
            # 同样，走不通说明连前缀都没有
            if not p.children[idx]:
                return False
            p = p.children[idx]
            
        # 前缀和 search 唯一的区别：只要路能通，不管走到最后有没有插红旗，前缀都是存在的！
        return True


if __name__ == &quot;__main__&quot;:
    ops = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    args = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))

    ans = []
    trie = None

    for op, arg in zip(ops, args):
        if op == &quot;Trie&quot;:
            trie = Trie()
            ans.append(None)
        elif op == &quot;insert&quot;:
            trie.insert(arg[0])
            ans.append(None)
        elif op == &quot;search&quot;:
            ans.append(trie.search(arg[0]))
        elif op == &quot;startsWith&quot;:
            ans.append(trie.startsWith(arg[0]))

    print(str(ans).replace(&quot;None&quot;, &quot;null&quot;).replace(&quot;True&quot;, &quot;true&quot;).replace(&quot;False&quot;, &quot;false&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;记住思路了背板很简单&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;差不多能秒，还是多看板子，写的很优雅。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;全排列&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;46. 全排列&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个不含重复数字的数组 &lt;code&gt;nums&lt;/code&gt; ，返回其 &lt;em&gt;所有可能的全排列&lt;/em&gt; 。你可以  &lt;strong&gt;按任意顺序&lt;/strong&gt;  返回答案。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,2,3]
输出：[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [0,1]
输出：[[0,1],[1,0]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1]
输出：[[1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 6&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10 &amp;lt;= nums[i] &amp;lt;= 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 中的所有整数  &lt;strong&gt;互不相同&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 最朴素的想法就是建立一个寻找第j位置的函数，然后一个同样大小的seen数组维持是否被用过，每次遍历找一个，直到长度达到要求结束。
import ast

def solution(nums: list) -&amp;gt; list[list[int]]:
    ans = []
    path = []
    seen = [False] * len(nums)

    def dfs():
        if len(path) == len(nums):
            ans.append(path[:])
            return

        for i in range(len(nums)):
            if seen[i]:
                continue
            # 递归开始的时候，标记seen，加入path
            seen[i] = True
            path.append(nums[i])
            # 递归
            dfs()
            # 递归结束的时候，弹出path，更新seen
            path.pop()
            seen[i] = False

    dfs()
    return ans


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
    
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;用python写的话，一般都是把递归写成函数中的函数，而不是用全局量。这一题的思路不难，主要是递归设计容易弄错。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;首先是dfs的选择，适用于选择一条答案走到底再回头。另外，二刷选择了set，当然这一题数值不重复是没问题的，但是最好记录“下标是否使用过”，这样有重复元素也没关系。也就是说，我们用seen = [False] * n&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;子集&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;78. 子集&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; ，数组中的元素  &lt;strong&gt;互不相同&lt;/strong&gt;  。返回该数组所有可能的子集（幂集）。&lt;/p&gt;
&lt;p&gt;解集  &lt;strong&gt;不能&lt;/strong&gt;  包含重复的子集。你可以按  &lt;strong&gt;任意顺序&lt;/strong&gt;  返回解集。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,2,3]
输出：[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [0]
输出：[[],[0]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10 &amp;lt;= nums[i] &amp;lt;= 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 中的所有元素  &lt;strong&gt;互不相同&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 元素不能重复天然想到set，依旧递归，每个元素都可以选择在或不在子集，因此遍历一遍判断每个元素就可以构成解集
import ast

def solution(nums: list) -&amp;gt; list[list[int]]:
    ans = []
    path = []

    def dfs(i: int):
        if i == len(nums):
            ans.append(path[:])
            return

        # 不选 nums[i]
        dfs(i + 1)

        # 选 nums[i]
        path.append(nums[i])
        dfs(i + 1)
        path.pop()

    dfs(0)
    return ans

        
if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;同样是比较标准的递归，属于是不能回头查的递归，所以维持一个深度i（这里就是num的长度），递归过程中达到深度存下所有叶子答案&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;这一题也可以像上一题一样遍历添加，然后回溯，只要给dfs维持一个start就可以保证不重复添加。这种方法可以保证顺序比较符合人类直觉：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast
def solution(nums):
    ans = []
    path = []

    def dfs(start):
        # 先收集当前子集
        ans.append(path[:])   
        for i in range(start, len(nums)):
            path.append(nums[i])
            dfs(i + 1)
            path.pop()

    dfs(0)
    return ans


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;电话号码的字母组合&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;17. 电话号码的字母组合&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;给定一个仅包含数字 &lt;code&gt;2-9&lt;/code&gt; 的字符串，返回所有它能表示的字母组合。答案可以按  &lt;strong&gt;任意顺序&lt;/strong&gt;  返回。&lt;/p&gt;
&lt;p&gt;给出数字到字母的映射如下（与电话按键相同）。注意 1 不对应任何字母。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pic.leetcode.cn/1752723054-mfIHZs-image.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：digits = &quot;23&quot;
输出：[&quot;ad&quot;,&quot;ae&quot;,&quot;af&quot;,&quot;bd&quot;,&quot;be&quot;,&quot;bf&quot;,&quot;cd&quot;,&quot;ce&quot;,&quot;cf&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：digits = &quot;2&quot;
输出：[&quot;a&quot;,&quot;b&quot;,&quot;c&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= digits.length &amp;lt;= 4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;digits[i]&lt;/code&gt; 是范围 &lt;code&gt;[&apos;2&apos;, &apos;9&apos;]&lt;/code&gt; 的一个数字。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 用哈希表存储每个数字字符对应的字符串，当我们输入连续数字的时候，实际上在求它们对应字符串组成列表的笛卡尔积。

def solution(digits: str) -&amp;gt; list[str]:
    if not digits:
        return []

    mp = {
        &quot;2&quot;: &quot;abc&quot;, &quot;3&quot;: &quot;def&quot;, &quot;4&quot;: &quot;ghi&quot;, &quot;5&quot;: &quot;jkl&quot;,
        &quot;6&quot;: &quot;mno&quot;, &quot;7&quot;: &quot;pqrs&quot;, &quot;8&quot;: &quot;tuv&quot;, &quot;9&quot;: &quot;wxyz&quot;
    }

    ans = []
    path = []

    def dfs(i: int):
        if i == len(digits):
            ans.append(&quot;&quot;.join(path))
            return

        for ch in mp[digits[i]]:
            path.append(ch)
            dfs(i + 1)
            path.pop()

    dfs(0)
    return ans

if __name__ == &quot;__main__&quot;:
    digits = input().strip()
    print(solution(digits))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;又是一个很好的递归，这题的情形是求笛卡尔积，跟上一题一样，是固定位数的递归，不过上一题是选或不选，这一题是选什么，所以多了一个循环来看选什么，递归写在循环里。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;写错了一个关键的点，就是这题同样属于逐位判断，dfs维持一个位置（换句话说不能回头选）。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;组合总和&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;39. 组合总和&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个  &lt;strong&gt;无重复元素&lt;/strong&gt;  的整数数组 &lt;code&gt;candidates&lt;/code&gt; 和一个目标整数 &lt;code&gt;target&lt;/code&gt; ，找出 &lt;code&gt;candidates&lt;/code&gt; 中可以使数字和为目标数 &lt;code&gt;target&lt;/code&gt; 的 所有  &lt;strong&gt;不同组合&lt;/strong&gt;  ，并以列表形式返回。你可以按  &lt;strong&gt;任意顺序&lt;/strong&gt;  返回这些组合。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;candidates&lt;/code&gt; 中的  &lt;strong&gt;同一个&lt;/strong&gt;  数字可以  &lt;strong&gt;无限制重复被选取&lt;/strong&gt;  。如果至少一个数字的被选数量不同，则两种组合是不同的。&lt;/p&gt;
&lt;p&gt;对于给定的输入，保证和为 &lt;code&gt;target&lt;/code&gt; 的不同组合数少于 &lt;code&gt;150&lt;/code&gt; 个。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：candidates = [2,3,6,7], target = 7
输出：[[2,2,3],[7]]
解释：
2 和 3 可以形成一组候选，2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选， 7 = 7 。
仅有这两种组合。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: candidates = [2], target = 1
输出: []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= candidates.length &amp;lt;= 30&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2 &amp;lt;= candidates[i] &amp;lt;= 40&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;candidates&lt;/code&gt; 的所有元素  &lt;strong&gt;互不相同&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= target &amp;lt;= 40&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 其实所有组合也就是每一位选或者不选，但是这题是允许重复选，然后就是可以根据target稍微剪枝一下，如果和已经大于target了，那这条路肯定就不通了

import ast

def solution(candidates: list, target: int) -&amp;gt; list[list[int]]:
    ans = []
    path = []

    def dfs(i: int, target: int):
        if target == 0:
            ans.append(path[:])
            return
        if i == len(candidates) or target &amp;lt; 0:
            return

        # 不选当前数
        dfs(i + 1, target)

        # 选当前数，还可以继续选它
        path.append(candidates[i])
        dfs(i, target - candidates[i])
        path.pop()

    dfs(0, target)
    return ans


if __name__ == &quot;__main__&quot;:
    candidates = ast.literal_eval(input().strip())
    target = int(input().strip())
    print(solution(candidates,target))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;本题是可以重复选的，其实也是只要分为选和不选，但是选的时候不用移动i就行了。然后同时用i和target监视。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;第一遍错误按照所有组合算总和，223、232、322都算进去了。但是，实际上可以维持一个循环开始搜索的位置start就可以保证不回头选，这也说明了维持位置i和维持start开始遍历是某些情况等效的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;i 版本：显式地写“选 / 不选当前下标”&lt;/li&gt;
&lt;li&gt;start 版本：用 for 一次性枚举“从当前下标开始能选谁”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其实后一种情况可以适配的情况还更多，包括组合、子集、组合总和I/II、固定长度组合都可以用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast
def solution(condidates:list,target:int)-&amp;gt;list[list[int]]:
    ans = []
    path = []
    def dfs(start,total):
        if total &amp;gt; target:
            return 
        if target == total:
           ans.append(path[:])
           return 
        for i in range(start,len(condidates)):
            path.append(condidates[i])
            # 可重复选，所以递归进去还是i
            dfs(i,total+condidates[i]) 
            path.pop()
    dfs(0, 0)
    return ans

if __name__ == &quot;__main__&quot;:
    condidates = ast.literal_eval(input().strip())
    target = int(input().strip())
    print(solution(condidates,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;括号生成&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;22. 括号生成&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;数字 &lt;code&gt;n&lt;/code&gt; 代表生成括号的对数，请你设计一个函数，用于能够生成所有可能的并且  &lt;strong&gt;有效的&lt;/strong&gt;  括号组合。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 3
输出：[&quot;((()))&quot;,&quot;(()())&quot;,&quot;(())()&quot;,&quot;()(())&quot;,&quot;()()()&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 1
输出：[&quot;()&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 8&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 标准回溯思路其实很简单：
# 如果左括号还没用满，就可以放 &apos;(&apos;
# 如果右括号数量小于左括号数量，就可以放 &apos;)&apos;


def solution(n: int):
    ans = []
    path = []

    # dfs直接维持left和right
    def dfs(left: int, right: int):
        # 递归出口
        if left == n and right == n:
            ans.append(&quot;&quot;.join(path))
            return
        # 左括号没满
        if left &amp;lt; n:
            path.append(&quot;(&quot;)
            dfs(left + 1, right)
            path.pop()
        # 左括号比较多的时候可以放右括号
        if right &amp;lt; left:
            path.append(&quot;)&quot;)
            dfs(left, right + 1)
            path.pop()

    dfs(0, 0)
    return ans


if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    print(solution(n))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;难点在dfs的边界条件，还有这里传入的量也不一样，每一题dfs可以灵活传入不同的量，也可以直接按每题都dfs空，把需要维护的量写外面nonlocal。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;SKIP了，括号好烦好烦，等会做专题一并解决。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;单词搜索&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;79. 单词搜索&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个 &lt;code&gt;m x n&lt;/code&gt; 二维字符网格 &lt;code&gt;board&lt;/code&gt; 和一个字符串单词 &lt;code&gt;word&lt;/code&gt; 。如果 &lt;code&gt;word&lt;/code&gt; 存在于网格中，返回 &lt;code&gt;true&lt;/code&gt; ；否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;单词必须按照字母顺序，通过相邻的单元格内的字母构成，其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/11/04/word2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：board = [[&apos;A&apos;,&apos;B&apos;,&apos;C&apos;,&apos;E&apos;],[&apos;S&apos;,&apos;F&apos;,&apos;C&apos;,&apos;S&apos;],[&apos;A&apos;,&apos;D&apos;,&apos;E&apos;,&apos;E&apos;]], word = &quot;ABCCED&quot;
输出：true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/11/04/word-1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：board = [[&apos;A&apos;,&apos;B&apos;,&apos;C&apos;,&apos;E&apos;],[&apos;S&apos;,&apos;F&apos;,&apos;C&apos;,&apos;S&apos;],[&apos;A&apos;,&apos;D&apos;,&apos;E&apos;,&apos;E&apos;]], word = &quot;SEE&quot;
输出：true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/15/word3.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：board = [[&apos;A&apos;,&apos;B&apos;,&apos;C&apos;,&apos;E&apos;],[&apos;S&apos;,&apos;F&apos;,&apos;C&apos;,&apos;S&apos;],[&apos;A&apos;,&apos;D&apos;,&apos;E&apos;,&apos;E&apos;]], word = &quot;ABCB&quot;
输出：false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == board.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n = board[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 6&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= word.length &amp;lt;= 15&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;board&lt;/code&gt; 和 &lt;code&gt;word&lt;/code&gt; 仅由大小写英文字母组成&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你可以使用搜索剪枝的技术来优化解决方案，使其在 &lt;code&gt;board&lt;/code&gt; 更大的情况下可以更快解决问题？&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 一眼二维网络多起点深搜，思路不难难的是边界，来试试看。

import ast

def solution(board: list[list[str]], word: str) -&amp;gt; bool:
    m, n = len(board), len(board[0])
    # dfs(i,j,k)表示当前在ij，要匹配word[k]开始的后缀
    def dfs(i: int, j: int, k: int) -&amp;gt; bool:
        # 特别注意dfs的返回
        # 越界返回
        if i &amp;lt; 0 or i &amp;gt;= m or j &amp;lt; 0 or j &amp;gt;= n:
            return False
        # 非需要字母返回
        if board[i][j] != word[k]:
            return False
        # 长度达标返回True
        if k == len(word) - 1:
            return True

        # 标记，防止重复使用
        ch = board[i][j]
        board[i][j] = &quot;#&quot;

        found = (
            dfs(i - 1, j, k + 1)
            or dfs(i + 1, j, k + 1)
            or dfs(i, j - 1, k + 1)
            or dfs(i, j + 1, k + 1)
        )

        board[i][j] = ch
        return found

    # 多起点dfs
    # 本题不需要path，匹配完成就是满足的
    for i in range(m):
        for j in range(n):
            if dfs(i, j, 0):
                return True

    return False


if __name__ == &quot;__main__&quot;:
    board = ast.literal_eval(input().strip())
    word = input().strip()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;多源dfs，注意图dfs的时候要标记自身（或者用visted的），递归完再恢复。有越界和非字母两种返回情形。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;分割回文串&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;131. 分割回文串&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个字符串 &lt;code&gt;s&lt;/code&gt;，请你将 &lt;code&gt;s&lt;/code&gt; 分割成一些 子串，使每个子串都是  &lt;strong&gt;回文串&lt;/strong&gt;  。返回 &lt;code&gt;s&lt;/code&gt; 所有可能的分割方案。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;aab&quot;
输出：[[&quot;a&quot;,&quot;a&quot;,&quot;b&quot;],[&quot;aa&quot;,&quot;b&quot;]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;a&quot;
输出：[[&quot;a&quot;]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= s.length &amp;lt;= 16&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 仅由小写英文字母组成&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 分割回文串的个数不定，回文串判断是反转和原本一样。
# 这题关键的视角是站在某个起点start，决定下一刀要切在哪

def solution(s: str) -&amp;gt; list[list[str]]:
    ans = []
    path = []

    # 辅助函数，判断是不是回文串
    def is_palindrome(sub: str) -&amp;gt; bool:
        return sub == sub[::-1]

    def dfs(start: int):
        if start == len(s):
            # 切到了终点
            ans.append(path[:])
            return

        for end in range(start, len(s)):
            # 一个一个往后切，加入路径
            sub = s[start:end + 1]
            if not is_palindrome(sub):
                continue

            path.append(sub)
            dfs(end + 1)
            path.pop()

    dfs(0)
    return ans

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;N皇后&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;51. N 皇后&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;按照国际象棋的规则，皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;n 皇后问题&lt;/strong&gt;  研究的是如何将 &lt;code&gt;n&lt;/code&gt; 个皇后放置在 &lt;code&gt;n×n&lt;/code&gt; 的棋盘上，并且使皇后彼此之间不能相互攻击。&lt;/p&gt;
&lt;p&gt;给你一个整数 &lt;code&gt;n&lt;/code&gt; ，返回所有不同的  &lt;strong&gt;n 皇后问题&lt;/strong&gt;  的解决方案。&lt;/p&gt;
&lt;p&gt;每一种解法包含一个不同的  &lt;strong&gt;n 皇后问题&lt;/strong&gt;  的棋子放置方案，该方案中 &lt;code&gt;&apos;Q&apos;&lt;/code&gt; 和 &lt;code&gt;&apos;.&apos;&lt;/code&gt; 分别代表了皇后和空位。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/11/13/queens.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 4
输出：[[&quot;.Q..&quot;,&quot;...Q&quot;,&quot;Q...&quot;,&quot;..Q.&quot;],[&quot;..Q.&quot;,&quot;Q...&quot;,&quot;...Q&quot;,&quot;.Q..&quot;]]
解释：如上图所示，4 皇后问题存在两个不同的解法。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 1
输出：[[&quot;Q&quot;]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 9&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 久闻大名
# 直接背板子）
def solution(n: int) -&amp;gt; list[list[str]]:
    ans = []
    board = [[&quot;.&quot;] * n for _ in range(n)]

    col = [False] * n
    diag1 = [False] * (2 * n)   # 主对角线：row - col + n
    diag2 = [False] * (2 * n)   # 副对角线：row + col

    def dfs(row: int):
        if row == n:
            ans.append([&quot;&quot;.join(r) for r in board])
            return

        for c in range(n):
            d1 = row - c + n
            d2 = row + c

            if col[c] or diag1[d1] or diag2[d2]:
                continue

            board[row][c] = &quot;Q&quot;
            col[c] = True
            diag1[d1] = True
            diag2[d2] = True

            dfs(row + 1)

            board[row][c] = &quot;.&quot;
            col[c] = False
            diag1[d1] = False
            diag2[d2] = False

    dfs(0)
    return ans

if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    print(solution(n))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;搜索插入位置&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;35. 搜索插入位置&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个排序数组和一个目标值，在数组中找到目标值，并返回其索引。如果目标值不存在于数组中，返回它将会被按顺序插入的位置。&lt;/p&gt;
&lt;p&gt;请必须使用时间复杂度为 &lt;code&gt;O(log n)&lt;/code&gt; 的算法。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [1,3,5,6], target = 5
输出: 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [1,3,5,6], target = 2
输出: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [1,3,5,6], target = 7
输出: 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 为  &lt;strong&gt;无重复元素&lt;/strong&gt;  的  &lt;strong&gt;升序&lt;/strong&gt;  排列数组&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= target &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 · 左闭右闭&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 经典二分查找，搜索目标是第一个小于目标值的值，小于Target为条件，TTTFFF，第一个F就是要插入的位置
import ast

def search(nums:list[int],target:int)-&amp;gt;int:
    left = 0
    right = len(nums) - 1
    while left&amp;lt;right:
        mid = (left + right) // 2
        if nums[mid]&amp;lt;target:
            left = mid + 1
        else:
            right = mid
    if nums[-1]&amp;lt;target:
        return len(nums)
    return left

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    target = int(input().strip())
    print(search(nums,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 · 左闭右开&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 左闭右开版
import ast

def search(nums:list[int],target:int)-&amp;gt;int:
    left = 0
    right = len(nums)
    while left &amp;lt; right:
        mid = (left + right) // 2
        if nums[mid]&amp;lt;target:
            left = mid + 1
        else:
            right = mid
    return left

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    target = int(input().strip())
    print(search(nums,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;本题主要是要注意，在左闭右闭过程中，二分搜索中全T的情况，这样要判断末尾，left不会停到数组外。但是如果是左闭右开就无所谓。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;搜索二维矩阵&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;74. 搜索二维矩阵&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个满足下述两条属性的 &lt;code&gt;m x n&lt;/code&gt; 整数矩阵：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每行中的整数从左到右按非严格递增顺序排列。&lt;/li&gt;
&lt;li&gt;每行的第一个整数大于前一行的最后一个整数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;给你一个整数 &lt;code&gt;target&lt;/code&gt; ，如果 &lt;code&gt;target&lt;/code&gt; 在矩阵中，返回 &lt;code&gt;true&lt;/code&gt; ；否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/10/05/mat.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3
输出：true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.cn/aliyun-lc-upload/uploads/2020/11/25/mat2.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13
输出：false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == matrix.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == matrix[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= matrix[i][j], target &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题算是直接找两侧有序的退化版，直接展开然后二分就行
# 小于Target是T，TTTFFF，第一个F就（可能是）要求的值，在找值问题时候还要判断一下

import ast

def search(nums:list[int],target:int)-&amp;gt;bool:
    left = 0
    right = len(nums)
    while left &amp;lt; right:
        mid = (left + right) // 2
        if nums[mid]&amp;lt;target:
            left = mid + 1
        elif nums[mid]&amp;gt; target:
            right = mid
        elif nums[mid] == target:
            return True
    return False

if __name__ == &quot;__main__&quot;:
    matrix = ast.literal_eval(input().strip())
    target = int(input().strip())
    flat = [x for row in matrix for x in row]
    print(search(flat,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;在排序数组中查找元素的第一个和最后一个位置&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;34. 在排序数组中查找元素的第一个和最后一个位置&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个按照非递减顺序排列的整数数组 &lt;code&gt;nums&lt;/code&gt;，和一个目标值 &lt;code&gt;target&lt;/code&gt;。请你找出给定目标值在数组中的开始位置和结束位置。&lt;/p&gt;
&lt;p&gt;如果数组中不存在目标值 &lt;code&gt;target&lt;/code&gt;，返回 &lt;code&gt;[-1, -1]&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;你必须设计并实现时间复杂度为 &lt;code&gt;O(log n)&lt;/code&gt; 的算法解决此问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [5,7,7,8,8,10], target = 8
输出：[3,4]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [5,7,7,8,8,10], target = 6
输出：[-1,-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [], target = 0
输出：[-1,-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= nums[i] &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 是一个非递减数组&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= target &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 标准二分查找找左右边界
# 小于target是TTTFFF，小于等于target是TTTTFF，分别找第一个左边界和右边界

import ast

def left_bound(nums:list,target:int):
    left = 0
    right = len(nums)
    while left &amp;lt; right:
        mid = (left + right) // 2
        if nums[mid] &amp;lt; target:
            left = mid + 1
        else:
            right = mid
    # left可能出界
    if left == len(nums) or nums[left] != target:
        return -1
    return left


def right_bound(nums:list,target:int):
    left = 0
    right = len(nums)
    while left &amp;lt; right:
        mid = (left + right) // 2
        if nums[mid] &amp;lt;= target:
            left = mid + 1
        else:
            right = mid
    # left可能出界
    if left == 0 or nums[left-1] != target:
        return -1
    return left-1

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    target = int(input().strip())
    left = left_bound(nums,target)
    right = right_bound(nums,target)
    print([left,right])
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;采取了统一的TTFFF方法，注意左闭右开left最后可能出界，然后右边界的时候可能left-1越界，细看边界保护就好。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;统一采用left落在第一个T的二分查找，即左闭右开，然后特殊判断是否满足题目要求即可。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;搜索旋转排序数组&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;33. 搜索旋转排序数组&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;整数数组 &lt;code&gt;nums&lt;/code&gt; 按升序排列，数组中的值  &lt;strong&gt;互不相同&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;在传递给函数之前，&lt;code&gt;nums&lt;/code&gt; 在预先未知的某个下标 &lt;code&gt;k&lt;/code&gt;（&lt;code&gt;0 &amp;lt;= k &amp;lt; nums.length&lt;/code&gt;）上进行了  &lt;strong&gt;向左旋转&lt;/strong&gt; ，使数组变为 &lt;code&gt;[nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]&lt;/code&gt;（下标  &lt;strong&gt;从 0 开始&lt;/strong&gt;  计数）。例如， &lt;code&gt;[0,1,2,4,5,6,7]&lt;/code&gt; 下标 &lt;code&gt;3&lt;/code&gt; 上向左旋转后可能变为 &lt;code&gt;[4,5,6,7,0,1,2]&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;给你  &lt;strong&gt;旋转后&lt;/strong&gt;  的数组 &lt;code&gt;nums&lt;/code&gt; 和一个整数 &lt;code&gt;target&lt;/code&gt; ，如果 &lt;code&gt;nums&lt;/code&gt; 中存在这个目标值 &lt;code&gt;target&lt;/code&gt; ，则返回它的下标，否则返回 &lt;code&gt;-1&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;你必须设计一个时间复杂度为 &lt;code&gt;O(log n)&lt;/code&gt; 的算法解决此问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [4,5,6,7,0,1,2], target = 0
输出：4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [4,5,6,7,0,1,2], target = 3
输出：-1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1], target = 0
输出：-1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 5000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 中的每个值都  &lt;strong&gt;独一无二&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;题目数据保证 &lt;code&gt;nums&lt;/code&gt; 在预先未知的某个下标上进行了旋转&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= target &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 未知下标，我们可以利用二叉搜索的判断趋势的功能，先找到到底是在哪递增变递减的。
# 我们按nums最后的数来创建bool数组，可以知道前面k分界前面的数都是大于last的，后面的都是小于等于，所以nums[mid]&amp;gt;nums[-1]就可以构成 TTTFFF，第一个F就是原数组开头

import ast

def search_range(nums: list[int], left: int, right: int, target: int) -&amp;gt; int:
    # 在 [left, right) 内二分查找 target
    while left &amp;lt; right:
        mid = (left + right) // 2
        if nums[mid] &amp;lt; target:
            left = mid + 1
        elif nums[mid] &amp;gt; target:
            right = mid
        else:
            return mid
    return -1


def findK(nums: list[int]) -&amp;gt; int:
    # 找最小值下标，也就是旋转点
    left = 0
    right = len(nums)
    last = nums[-1]

    while left &amp;lt; right:
        mid = (left + right) // 2
        if nums[mid] &amp;gt; last:
            left = mid + 1
        else:
            right = mid

    return left


def solution(nums: list[int], target: int) -&amp;gt; int:
    if not nums:
        return -1

    k = findK(nums)
    n = len(nums)

    # 看看在哪半边，查询哪半边
    if nums[k] &amp;lt;= target &amp;lt;= nums[n - 1]:
        return search_range(nums, k, n, target)
    else:
        return search_range(nums, 0, k, target)


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    target = int(input().strip())
    # 先找到切分位置
    print(solution(nums,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这里findK对于找pivot的方法比较巧妙，可以仔细看看。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 二刷&lt;/h2&gt;
&lt;p&gt;二刷写错了找pivot的方式，属于错误判断了FFFTTT和FFFTFF，用趋势来判断不行，必须用数组最后一位来判断，小于等于它的为右半边的T，大于它的为左半边的F。&lt;/p&gt;
&lt;p&gt;但是，这一题更好的解法是直接一次二分，因为二分之后总有一半是有序的，先看target在不在这个有序区间内，在就进入这半边，不在就去另一边。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(nums, target):
    left, right = 0, len(nums)

    while left &amp;lt; right:
        mid = (left + right) // 2

        if nums[mid] == target:
            return mid

        # 左半部分有序
        if nums[left] &amp;lt;= nums[mid]:
            if nums[left] &amp;lt;= target &amp;lt; nums[mid]:
                right = mid
            else:
                left = mid + 1
        # 右半部分有序
        else:
            if nums[mid] &amp;lt; target &amp;lt;= nums[right - 1]:
                left = mid + 1
            else:
                right = mid

    return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;寻找旋转排序数组中的最小值&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;153. 寻找旋转排序数组中的最小值&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;已知一个长度为 &lt;code&gt;n&lt;/code&gt; 的数组，预先按照升序排列，经由 &lt;code&gt;1&lt;/code&gt; 到 &lt;code&gt;n&lt;/code&gt; 次  &lt;strong&gt;旋转&lt;/strong&gt;  后，得到输入数组。例如，原数组 &lt;code&gt;nums = [0,1,2,4,5,6,7]&lt;/code&gt; 在变化后可能得到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;若旋转 &lt;code&gt;4&lt;/code&gt; 次，则可以得到 &lt;code&gt;[4,5,6,7,0,1,2]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;若旋转 &lt;code&gt;7&lt;/code&gt; 次，则可以得到 &lt;code&gt;[0,1,2,4,5,6,7]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;注意，数组 &lt;code&gt;[a[0], a[1], a[2], ..., a[n-1]]&lt;/code&gt;  &lt;strong&gt;旋转一次&lt;/strong&gt;  的结果为数组 &lt;code&gt;[a[n-1], a[0], a[1], a[2], ..., a[n-2]]&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;给你一个元素值  &lt;strong&gt;互不相同&lt;/strong&gt;  的数组 &lt;code&gt;nums&lt;/code&gt; ，它原来是一个升序排列的数组，并按上述情形进行了多次旋转。请你找出并返回数组中的  &lt;strong&gt;最小元素&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;你必须设计一个时间复杂度为 &lt;code&gt;O(log n)&lt;/code&gt; 的算法解决此问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,4,5,1,2]
输出：1
解释：原数组为 [1,2,3,4,5] ，旋转 3 次得到输入数组。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [4,5,6,7,0,1,2]
输出：0
解释：原数组为 [0,1,2,4,5,6,7] ，旋转 4 次得到输入数组。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [11,13,15,17]
输出：11
解释：原数组为 [11,13,15,17] ，旋转 4 次得到输入数组。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n == nums.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 5000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-5000 &amp;lt;= nums[i] &amp;lt;= 5000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 中的所有整数  &lt;strong&gt;互不相同&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 原来是一个升序排序的数组，并进行了 &lt;code&gt;1&lt;/code&gt; 至 &lt;code&gt;n&lt;/code&gt; 次旋转&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 等于给上一题降低难度了

import ast

def findPivot(nums:list):
    left = 0
    right = len(nums)
    last = nums[-1]
    while left&amp;lt;right:
        mid = (left + right) // 2
        if nums[mid] &amp;gt; last:
            left = mid + 1
        else:
            right = mid
    return nums[left]

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(findPivot(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;寻找两个正序数组的中位数&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;4. 寻找两个正序数组的中位数&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给定两个大小分别为 &lt;code&gt;m&lt;/code&gt; 和 &lt;code&gt;n&lt;/code&gt; 的正序（从小到大）数组 &lt;code&gt;nums1&lt;/code&gt; 和 &lt;code&gt;nums2&lt;/code&gt;。请你找出并返回这两个正序数组的  &lt;strong&gt;中位数&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;算法的时间复杂度应该为 &lt;code&gt;O(log (m+n))&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums1 = [1,3], nums2 = [2]
输出：2.00000
解释：合并数组 = [1,2,3] ，中位数 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums1 = [1,2], nums2 = [3,4]
输出：2.50000
解释：合并数组 = [1,2,3,4] ，中位数 (2 + 3) / 2 = 2.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nums1.length == m&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums2.length == n&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= m &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= n &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m + n &amp;lt;= 2000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^6 &amp;lt;= nums1[i], nums2[i] &amp;lt;= 10^6&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题要求复杂度O(log (m+n))那就不能用合并做了。需要用二分。
# 二分切分较短数组，找到一个切点，使得左半部分总长度等于右半部分，并且左边最大值 &amp;lt;= 右边最小值。

def solution(nums1: list[int], nums2: list[int]) -&amp;gt; float:
    # 保证在更短的数组上二分
    if len(nums1) &amp;gt; len(nums2):
        nums1, nums2 = nums2, nums1

    m, n = len(nums1), len(nums2)
    total_left = (m + n + 1) // 2

    left, right = 0, m

    while left &amp;lt;= right:
        i = (left + right) // 2
        j = total_left - i

        nums1_left_max = float(&quot;-inf&quot;) if i == 0 else nums1[i - 1]
        nums1_right_min = float(&quot;inf&quot;) if i == m else nums1[i]

        nums2_left_max = float(&quot;-inf&quot;) if j == 0 else nums2[j - 1]
        nums2_right_min = float(&quot;inf&quot;) if j == n else nums2[j]

        if nums1_left_max &amp;lt;= nums2_right_min and nums2_left_max &amp;lt;= nums1_right_min:
            if (m + n) % 2 == 1:
                return float(max(nums1_left_max, nums2_left_max))
            return (
                max(nums1_left_max, nums2_left_max)
                + min(nums1_right_min, nums2_right_min)
            ) / 2.0

        elif nums1_left_max &amp;gt; nums2_right_min:
            right = i - 1
        else:
            left = i + 1



if __name__ == &quot;__main__&quot;:
    nums1 = list(map(int,input().strip().split(&apos;,&apos;)))
    nums2 = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums1,nums2))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;直接O(m+n)很简单，二分太复杂了好恶心，暂时先不看。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;有效的括号&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;20. 有效的括号&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个只包括 &lt;code&gt;&apos;(&apos;&lt;/code&gt;，&lt;code&gt;&apos;)&apos;&lt;/code&gt;，&lt;code&gt;&apos;{&apos;&lt;/code&gt;，&lt;code&gt;&apos;}&apos;&lt;/code&gt;，&lt;code&gt;&apos;[&apos;&lt;/code&gt;，&lt;code&gt;&apos;]&apos;&lt;/code&gt; 的字符串 &lt;code&gt;s&lt;/code&gt; ，判断字符串是否有效。&lt;/p&gt;
&lt;p&gt;有效字符串需满足：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;左括号必须用相同类型的右括号闭合。&lt;/li&gt;
&lt;li&gt;左括号必须以正确的顺序闭合。&lt;/li&gt;
&lt;li&gt;每个右括号都有一个对应的相同类型的左括号。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**s = &quot;()&quot;&lt;/p&gt;
&lt;p&gt;**输出：**true&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**s = &quot;()[]{}&quot;&lt;/p&gt;
&lt;p&gt;**输出：**true&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**s = &quot;(]&quot;&lt;/p&gt;
&lt;p&gt;**输出：**false&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 4：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**s = &quot;([])&quot;&lt;/p&gt;
&lt;p&gt;**输出：**true&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 5：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**s = &quot;([)]&quot;&lt;/p&gt;
&lt;p&gt;**输出：**false&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= s.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 仅由括号 &lt;code&gt;&apos;()[]{}&apos;&lt;/code&gt; 组成&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 经典判断有效性
# 用一个栈，不断入，遇到对应左括号弹出，最后看栈空不空就行

def solution(s: str) -&amp;gt; bool:
    stack = []
    mp = {
        &apos;)&apos;: &apos;(&apos;,
        &apos;]&apos;: &apos;[&apos;,
        &apos;}&apos;: &apos;{&apos;
    }

    for ch in s:
        if ch in mp:
            if not stack or stack[-1] != mp[ch]:
                return False
            stack.pop()
        else:
            stack.append(ch)

    return len(stack) == 0

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;最小栈&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;155. 最小栈&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;设计一个支持 &lt;code&gt;push&lt;/code&gt; ，&lt;code&gt;pop&lt;/code&gt; ，&lt;code&gt;top&lt;/code&gt; 操作，并能在常数时间内检索到最小元素的栈。&lt;/p&gt;
&lt;p&gt;实现 &lt;code&gt;MinStack&lt;/code&gt; 类:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MinStack()&lt;/code&gt; 初始化堆栈对象。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;void push(int val)&lt;/code&gt; 将元素val推入堆栈。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;void pop()&lt;/code&gt; 删除堆栈顶部的元素。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;int top()&lt;/code&gt; 获取堆栈顶部的元素。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;int getMin()&lt;/code&gt; 获取堆栈中的最小元素。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：
[&quot;MinStack&quot;,&quot;push&quot;,&quot;push&quot;,&quot;push&quot;,&quot;getMin&quot;,&quot;pop&quot;,&quot;top&quot;,&quot;getMin&quot;]
[[],[-2],[0],[-3],[],[],[],[]]

输出：
[null,null,null,null,-3,null,0,-2]

解释：
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin();   --&amp;gt; 返回 -3.
minStack.pop();
minStack.top();      --&amp;gt; 返回 0.
minStack.getMin();   --&amp;gt; 返回 -2.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-2^31 &amp;lt;= val &amp;lt;= 2^31 - 1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pop&lt;/code&gt;、&lt;code&gt;top&lt;/code&gt; 和 &lt;code&gt;getMin&lt;/code&gt; 操作总是在  &lt;strong&gt;非空栈&lt;/strong&gt;  上调用&lt;/li&gt;
&lt;li&gt;&lt;code&gt;push&lt;/code&gt;, &lt;code&gt;pop&lt;/code&gt;, &lt;code&gt;top&lt;/code&gt;, and &lt;code&gt;getMin&lt;/code&gt;最多被调用 &lt;code&gt;3 * 10^4&lt;/code&gt; 次&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 单调栈是两个栈实现的

import ast

class MinStack:
    def __init__(self):
        self.stack = []
        self.min_stack = []

    def push(self, val: int) -&amp;gt; None:
        self.stack.append(val)
        if not self.min_stack:
            self.min_stack.append(val)
        # 比较小才会加入栈
        else:
            self.min_stack.append(min(val, self.min_stack[-1]))

    def pop(self) -&amp;gt; None:
        self.stack.pop()
        self.min_stack.pop()

    def top(self) -&amp;gt; int:
        return self.stack[-1]

    def getMin(self) -&amp;gt; int:
        return self.min_stack[-1]


if __name__ == &quot;__main__&quot;:
    ops = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))
    args = ast.literal_eval(input().strip().replace(&quot;null&quot;, &quot;None&quot;))

    ans = []
    obj = None

    for op, arg in zip(ops, args):
        if op == &quot;MinStack&quot;:
            obj = MinStack()
            ans.append(None)
        elif op == &quot;push&quot;:
            obj.push(arg[0])
            ans.append(None)
        elif op == &quot;pop&quot;:
            obj.pop()
            ans.append(None)
        elif op == &quot;top&quot;:
            ans.append(obj.top())
        elif op == &quot;getMin&quot;:
            ans.append(obj.getMin())

    print(str(ans).replace(&quot;None&quot;, &quot;null&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;字符串编码&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;394. 字符串解码&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个经过编码的字符串，返回它解码后的字符串。&lt;/p&gt;
&lt;p&gt;编码规则为: &lt;code&gt;k[encoded_string]&lt;/code&gt;，表示其中方括号内部的 &lt;code&gt;encoded_string&lt;/code&gt; 正好重复 &lt;code&gt;k&lt;/code&gt; 次。注意 &lt;code&gt;k&lt;/code&gt; 保证为正整数。&lt;/p&gt;
&lt;p&gt;你可以认为输入字符串总是有效的；输入字符串中没有额外的空格，且输入的方括号总是符合格式要求的。&lt;/p&gt;
&lt;p&gt;此外，你可以认为原始数据不包含数字，所有的数字只表示重复的次数 &lt;code&gt;k&lt;/code&gt; ，例如不会出现像 &lt;code&gt;3a&lt;/code&gt; 或 &lt;code&gt;2[4]&lt;/code&gt; 的输入。&lt;/p&gt;
&lt;p&gt;测试用例保证输出的长度不会超过 &lt;code&gt;10^5&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;3[a]2[bc]&quot;
输出：&quot;aaabcbc&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;3[a2[c]]&quot;
输出：&quot;accaccacc&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;2[abc]3[cd]ef&quot;
输出：&quot;abcabccdcdcdef&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 4：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;abc3[cd]xyz&quot;
输出：&quot;abccdcdcdxyz&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= s.length &amp;lt;= 30&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 由小写英文字母、数字和方括号 &lt;code&gt;&apos;[]&apos;&lt;/code&gt; 组成&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 保证是一个  &lt;strong&gt;有效&lt;/strong&gt;  的输入。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 中所有整数的取值范围为 &lt;code&gt;[1, 300]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 思路确实比较难想，先背板

from collections import deque

def decodeString(s: str) -&amp;gt; str:
    # 重新尝试自己写出来代码
    # 缓冲目前字符
    curr_buffer=[]
    stack=[]
    # 记录倍率数字，注意可能不止一位数
    mul=0
    for ch in s:
        # 检查是否为左括号，如果是，要把上一轮的buffer和数字全部入栈，并清理状态
        if ch==&apos;[&apos;:
            curr_name=&quot;&quot;.join(curr_buffer)               
            stack.append((curr_name,mul))
            curr_buffer=[]
            mul=0
        # 碰到右括号，可以开始处理之前冻结（还无法处理的）字符串了，乘以其倍率添加到curr
        elif ch == &apos;]&apos;:
            curr_name=&quot;&quot;.join(curr_buffer)
            # 过去的倍率千万不能覆盖现在的mul
            last_str,old_mul=stack.pop()
            curr_buffer=[last_str+curr_name*old_mul]
        # 如果是数字，则判断之前有没有出现过数字，如果有则要加位数
        # 注意这里的数字存的是下一次curr的倍率而不是last的倍率
        elif ch.isdigit():
            mul=mul*10+int(ch)
        else:
            curr_buffer.append(ch)
    return &quot;&quot;.join(curr_buffer)

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(decodeString(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;每日温度&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;739. 每日温度&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个整数数组 &lt;code&gt;temperatures&lt;/code&gt; ，表示每天的温度，返回一个数组 &lt;code&gt;answer&lt;/code&gt; ，其中 &lt;code&gt;answer[i]&lt;/code&gt; 是指对于第 &lt;code&gt;i&lt;/code&gt; 天，下一个更高温度出现在几天后。如果气温在这之后都不会升高，请在该位置用 &lt;code&gt;0&lt;/code&gt; 来代替。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: temperatures = [30,40,50,60]
输出: [1,1,1,0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: temperatures = [30,60,90]
输出: [1,1,0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= temperatures.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;30 &amp;lt;= temperatures[i] &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 找右边第一个比自己大的元素，将相对几天填入（也就是坐标差），这是经典单调栈，存下标

import ast
from collections import deque

def solution(temperatures:list)-&amp;gt;list:
    q = deque()
    n = len(temperatures)
    ans = [0] * n
    for i,val in enumerate(temperatures):
        while q and temperatures[q[-1]]&amp;lt; val:
            idx = q.pop()
            ans[idx] = i - idx
        q.append(i)
    return ans

if __name__ == &quot;__main__&quot;:
    temperatures = ast.literal_eval(input().strip())
    print(solution(temperatures))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;柱状图中最大的矩形&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;84. 柱状图中最大的矩形&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给定 &lt;em&gt;n&lt;/em&gt; 个非负整数，用来表示柱状图中各个柱子的高度。每个柱子彼此相邻，且宽度为 1 。&lt;/p&gt;
&lt;p&gt;求在该柱状图中，能够勾勒出来的矩形的最大面积。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/01/04/histogram.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：heights = [2,1,5,6,2,3]
输出：10
解释：最大的矩形为图中红色区域，面积为 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2021/01/04/histogram-1.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入： heights = [2,4]
输出： 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= heights.length &amp;lt;=10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= heights[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 我们可以构造一个单调递增栈，因为是找左侧第一个比curr小的，找到这个下标之后就可以用curr_idx - left_idx + 1当做宽度，用left_height当做高度，得出这里的矩阵面积
# 维持一个全局量max_S
# 为了保证全部弹出（因为弹出才结算），还有防止找不到左侧第一个比curr小的，需要两侧+0

import ast
from collections import deque

def solution(heights: list) -&amp;gt; int:
    heights = [0] + heights + [0]
    stack = []
    ans = 0

    for i, h in enumerate(heights):
        while stack and heights[stack[-1]] &amp;gt; h:
            idx = stack.pop()
            height = heights[idx]
            # 注意这里被弹出的柱子，还要看左边第一个比它小的，在到达那里之前还可以向左延伸
            width = i - stack[-1] - 1
            ans = max(ans, height * width)
        stack.append(i)

    return ans



if __name__ == &quot;__main__&quot;:
    heights = ast.literal_eval(input().strip())
    print(solution(heights))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;一定要注意还可以向左延伸。单调递增栈 -&amp;gt; 找左侧第一个比curr小的；单调递减栈 -&amp;gt; 找右侧第一个比弹出大的&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;数组中的第K个最大元素&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;215. 数组中的第K个最大元素&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定整数数组 &lt;code&gt;nums&lt;/code&gt; 和整数 &lt;code&gt;k&lt;/code&gt;，请返回数组中第 &lt;code&gt;**k**&lt;/code&gt; 个最大的元素。&lt;/p&gt;
&lt;p&gt;请注意，你需要找的是数组排序后的第 &lt;code&gt;k&lt;/code&gt; 个最大的元素，而不是第 &lt;code&gt;k&lt;/code&gt; 个不同的元素。&lt;/p&gt;
&lt;p&gt;你必须设计并实现时间复杂度为 &lt;code&gt;O(n)&lt;/code&gt; 的算法解决此问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: [3,2,1,5,6,4], k = 2
输出: 5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: [3,2,3,1,2,4,5,5,6], k = 4
输出: 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= k &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题在hot100中被归到了堆，但是堆是Onlogk的，而且k是动态的，严格的解法不能用堆。
# 这题的正确做法是quickselect，即快速选择算法

import ast
import random


def solution(nums: list[int], k: int) -&amp;gt; int:
    target = len(nums) - k  # 第k大 -&amp;gt; 升序下标 target

    def quick_select(left: int, right: int) -&amp;gt; int:
        pivot_idx = random.randint(left, right)
        pivot = nums[pivot_idx]

        # 先把 pivot 放到末尾
        nums[pivot_idx], nums[right] = nums[right], nums[pivot_idx]

        store = left
        for i in range(left, right):
            if nums[i] &amp;lt; pivot:
                nums[store], nums[i] = nums[i], nums[store]
                store += 1

        # pivot 放回最终位置
        nums[store], nums[right] = nums[right], nums[store]

        if store == target:
            return nums[store]
        elif store &amp;lt; target:
            return quick_select(store + 1, right)
        else:
            return quick_select(left, store - 1)

    return quick_select(0, len(nums) - 1)


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    k = int(input().strip())
    print(solution(nums, k))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;快速选择算法，没听说过！还得细看。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;前 K 个高频元素&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;347. 前 K 个高频元素&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; 和一个整数 &lt;code&gt;k&lt;/code&gt; ，请你返回其中出现频率前 &lt;code&gt;k&lt;/code&gt; 高的元素。你可以按  &lt;strong&gt;任意顺序&lt;/strong&gt;  返回答案。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**nums = [1,1,1,2,2,3], k = 2&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; [1,2]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**nums = [1], k = 1&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt;[1]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;**输入：**nums = [1,2,1,2,1,2,3,1,3,2], k = 2&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; [1,2]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;k&lt;/code&gt; 的取值范围是 &lt;code&gt;[1, 数组中不相同的元素的个数]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;题目数据保证答案唯一，换句话说，数组中前 &lt;code&gt;k&lt;/code&gt; 个高频元素的集合是唯一的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 你所设计算法的时间复杂度  &lt;strong&gt;必须&lt;/strong&gt;  优于 &lt;code&gt;O(n log n)&lt;/code&gt; ，其中 &lt;code&gt;n&lt;/code&gt; 是数组大小。&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 一眼堆，我们先转换成哈希表，然后存 value-key 到堆中，维持大小k

import heapq
import ast
from collections import Counter

def solution(nums:list,k:int):
    cnt = Counter(nums)
    heap = []

    for num, freq in cnt.items():
        heapq.heappush(heap, (freq, num))
        if len(heap) &amp;gt; k:
            heapq.heappop(heap)

    ans = [num for freq, num in heap]
    return ans

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    k = int(input().strip())
    print(solution(nums,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;数据流的中位数&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;295. 数据流的中位数&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;中位数&lt;/strong&gt; 是有序整数列表中的中间值。如果列表的大小是偶数，则没有中间值，中位数是两个中间值的平均值。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;例如 &lt;code&gt;arr = [2,3,4]&lt;/code&gt; 的中位数是 &lt;code&gt;3&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;例如 &lt;code&gt;arr = [2,3]&lt;/code&gt; 的中位数是 &lt;code&gt;(2 + 3) / 2 = 2.5&lt;/code&gt; 。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;实现 MedianFinder 类:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;MedianFinder()&lt;/code&gt; 初始化 &lt;code&gt;MedianFinder&lt;/code&gt; 对象。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;void addNum(int num)&lt;/code&gt; 将数据流中的整数 &lt;code&gt;num&lt;/code&gt; 添加到数据结构中。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;double findMedian()&lt;/code&gt; 返回到目前为止所有元素的中位数。与实际答案相差 &lt;code&gt;10^-5&lt;/code&gt; 以内的答案将被接受。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入
[&quot;MedianFinder&quot;, &quot;addNum&quot;, &quot;addNum&quot;, &quot;findMedian&quot;, &quot;addNum&quot;, &quot;findMedian&quot;]
[[], [1], [2], [], [3], []]
输出
[null, null, null, 1.5, null, 2.0]

解释
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1);    // arr = [1]
medianFinder.addNum(2);    // arr = [1, 2]
medianFinder.findMedian(); // 返回 1.5 ((1 + 2) / 2)
medianFinder.addNum(3);    // arr[1, 2, 3]
medianFinder.findMedian(); // return 2.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-10^5 &amp;lt;= num &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;在调用 &lt;code&gt;findMedian&lt;/code&gt; 之前，数据结构中至少有一个元素&lt;/li&gt;
&lt;li&gt;最多 &lt;code&gt;5 * 10^4&lt;/code&gt; 次调用 &lt;code&gt;addNum&lt;/code&gt; 和 &lt;code&gt;findMedian&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题的实现方法是维持两个堆，一个大根一个小根，先放进小根，然后保证small的最大小于large的最小，不然就拿出来扔最大里面。

import heapq
import ast

class MedianFinder:
    def __init__(self):
        # small: 大根堆（用相反数模拟），存较小的一半
        self.small = []
        # large: 小根堆，存较大的一半
        self.large = []

    def addNum(self, num: int) -&amp;gt; None:
        # 先放进 small
        heapq.heappush(self.small, -num)

        # 保证 small 里的最大值 &amp;lt;= large 里的最小值
        heapq.heappush(self.large, -heapq.heappop(self.small))

        # 保证 small 的元素个数 &amp;gt;= large，这样才能保证中位数放在small这边
        if len(self.large) &amp;gt; len(self.small):
            heapq.heappush(self.small, -heapq.heappop(self.large))

    def findMedian(self) -&amp;gt; float:
        # 如果small长，那就奇数
        if len(self.small) &amp;gt; len(self.large):
            return float(-self.small[0])
        # 否则是偶数
        return (-self.small[0] + self.large[0]) / 2.0

        

if __name__ == &quot;__main__&quot;:
    ops = ast.literal_eval(input().strip())
    vals = ast.literal_eval(input().strip())
    ans = []
    for op,val in zip(ops,vals):
        if op == &apos;MedianFinder&apos;:
            m = MedianFinder()
            ans.append(&quot;null&quot;)
        if op == &quot;addNum&quot;:
            m.addNum(val[0])
            ans.append(&quot;null&quot;)
        if op == &quot;findMedian&quot;:
            ans.append(m.findMedian())
    print(ans)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;买卖股票的最佳时机&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;121. 买卖股票的最佳时机&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个数组 &lt;code&gt;prices&lt;/code&gt; ，它的第 &lt;code&gt;i&lt;/code&gt; 个元素 &lt;code&gt;prices[i]&lt;/code&gt; 表示一支给定股票第 &lt;code&gt;i&lt;/code&gt; 天的价格。&lt;/p&gt;
&lt;p&gt;你只能选择  &lt;strong&gt;某一天&lt;/strong&gt;  买入这只股票，并选择在  &lt;strong&gt;未来的某一个不同的日子&lt;/strong&gt;  卖出该股票。设计一个算法来计算你所能获取的最大利润。&lt;/p&gt;
&lt;p&gt;返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润，返回 &lt;code&gt;0&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：[7,1,5,3,6,4]
输出：5
解释：在第 2 天（股票价格 = 1）的时候买入，在第 5 天（股票价格 = 6）的时候卖出，最大利润 = 6-1 = 5 。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格；同时，你不能在买入前卖出股票。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：prices = [7,6,4,3,1]
输出：0
解释：在这种情况下, 没有交易完成, 所以最大利润为 0。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= prices.length &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= prices[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 本质上是找高度差最大的两个元素，我们遍历的时候维持两个量 - 目前见过的最低价格，今天卖出可以得到的利润

import ast


def solution(prices: list[int]) -&amp;gt; int:
    min_price = float(&quot;inf&quot;)
    ans = 0

    for price in prices:
        min_price = min(min_price, price)
        # 今天卖出的利润和过去卖出的利润比较
        ans = max(ans, price - min_price)

    return ans


if __name__ == &quot;__main__&quot;:
    prices = ast.literal_eval(input().strip())
    print(solution(prices))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;跳跃游戏&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;55. 跳跃游戏&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个非负整数数组 &lt;code&gt;nums&lt;/code&gt; ，你最初位于数组的  &lt;strong&gt;第一个下标&lt;/strong&gt;  。数组中的每个元素代表你在该位置可以跳跃的最大长度。&lt;/p&gt;
&lt;p&gt;判断你是否能够到达最后一个下标，如果可以，返回 &lt;code&gt;true&lt;/code&gt; ；否则，返回 &lt;code&gt;false&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [2,3,1,1,4]
输出：true
解释：可以先跳 1 步，从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,2,1,0,4]
输出：false
解释：无论怎样，总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 ， 所以永远不可能到达最后一个下标。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= nums[i] &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 做法是维持当前能到达的最远距离max(当前最远可达位置, i + nums[i])

import ast


def solution(nums: list[int]) -&amp;gt; bool:
    farthest = 0

    for i, step in enumerate(nums):
        if i &amp;gt; farthest:
            return False
        farthest = max(farthest, i + step)

    return True


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;跳跃游戏 II&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;45. 跳跃游戏 II&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个长度为 &lt;code&gt;n&lt;/code&gt; 的  &lt;strong&gt;0 索引&lt;/strong&gt; 整数数组 &lt;code&gt;nums&lt;/code&gt;。初始位置在下标 0。&lt;/p&gt;
&lt;p&gt;每个元素 &lt;code&gt;nums[i]&lt;/code&gt; 表示从索引 &lt;code&gt;i&lt;/code&gt; 向后跳转的最大长度。换句话说，如果你在索引 &lt;code&gt;i&lt;/code&gt; 处，你可以跳转到任意 &lt;code&gt;(i + j)&lt;/code&gt; 处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= j &amp;lt;= nums[i]&lt;/code&gt; 且&lt;/li&gt;
&lt;li&gt;&lt;code&gt;i + j &amp;lt; n&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;返回到达 &lt;code&gt;n - 1&lt;/code&gt; 的最小跳跃次数。测试用例保证可以到达 &lt;code&gt;n - 1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
     从下标为 0 跳到下标为 1 的位置，跳 1 步，然后跳 3 步到达数组的最后一个位置。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [2,3,0,1,4]
输出: 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= nums[i] &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;题目保证可以到达 &lt;code&gt;n - 1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 还是维持一个目前能跳到的最远距离遍历，为了记录步数再多维持一个steps。当i达到最远时steps+1

import ast


def solution(nums: list[int]) -&amp;gt; int:
    steps = 0
    end = 0
    farthest = 0

    # 最后一个位置不用再跳了，所以遍历到 n-2 即可
    # 跳到最后一个位置这件事，是在前一个“边界结算”里就已经计数了，不需要等真的站上最后一个格子再加一次。
    for i in range(len(nums) - 1):
        farthest = max(farthest, i + nums[i])

        # 走到当前这一步的边界，必须进行下一跳
        if i == end:
            steps += 1
            end = farthest

    return steps


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))

&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;划分字母区间&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;763. 划分字母区间&lt;/h4&gt;
&lt;p&gt;难度：1443&lt;/p&gt;
&lt;p&gt;给你一个字符串 &lt;code&gt;s&lt;/code&gt; 。我们要把这个字符串划分为尽可能多的片段，同一字母最多出现在一个片段中。例如，字符串 &lt;code&gt;&quot;ababcc&quot;&lt;/code&gt; 能够被分为 &lt;code&gt;[&quot;abab&quot;, &quot;cc&quot;]&lt;/code&gt;，但类似 &lt;code&gt;[&quot;aba&quot;, &quot;bcc&quot;]&lt;/code&gt; 或 &lt;code&gt;[&quot;ab&quot;, &quot;ab&quot;, &quot;cc&quot;]&lt;/code&gt; 的划分是非法的。&lt;/p&gt;
&lt;p&gt;注意，划分结果需要满足：将所有划分结果按顺序连接，得到的字符串仍然是 &lt;code&gt;s&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;返回一个表示每个字符串片段的长度的列表。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;ababcbacadefegdehijhklij&quot;
输出：[9,7,8]
解释：
划分结果为 &quot;ababcbaca&quot;、&quot;defegde&quot;、&quot;hijhklij&quot; 。
每个字母最多出现在一个片段中。
像 &quot;ababcbacadefegde&quot;, &quot;hijhklij&quot; 这样的划分是错误的，因为划分的片段数较少。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;eccbbbbdec&quot;
输出：[10]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= s.length &amp;lt;= 500&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 仅由小写英文字母组成&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 第一次肯定弄不出来的贪心+1，我们看看题解思路吧

def solution(s: str) -&amp;gt; list[int]:
    last = {}
    # 记录每个字母最晚出现在哪
    for i, ch in enumerate(s):
        last[ch] = i

    ans = []
    start = 0
    end = 0

    # 贪心：当前分段的右边界 = 这一段里所有字符最后出现位置的最大值。
    for i, ch in enumerate(s):
        end = max(end, last[ch])

        if i == end:
            ans.append(end - start + 1)
            start = i + 1

    return ans

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;爬楼梯&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;70. 爬楼梯&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;假设你正在爬楼梯。需要 &lt;code&gt;n&lt;/code&gt; 阶你才能到达楼顶。&lt;/p&gt;
&lt;p&gt;每次你可以爬 &lt;code&gt;1&lt;/code&gt; 或 &lt;code&gt;2&lt;/code&gt; 个台阶。你有多少种不同的方法可以爬到楼顶呢？&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 2
输出：2
解释：有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 3
输出：3
解释：有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 45&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 基础动规

def solution(n:int)-&amp;gt;int:
    dp = [0]*(n+1)
    dp[0] = 1
    dp[1] = 1
    # 转移 dp[i] = dp[i-1] + dp[i-2]
    for i in range(2,n+1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    print(solution(n))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;杨辉三角&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;118. 杨辉三角&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个非负整数 _&lt;code&gt;numRows&lt;/code&gt;，_生成「杨辉三角」的前 &lt;em&gt;&lt;code&gt;numRows&lt;/code&gt;&lt;/em&gt; 行。&lt;/p&gt;
&lt;p&gt;在 &lt;strong&gt;「杨辉三角」&lt;/strong&gt; 中，每个数是它左上方和右上方的数的和。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pic.leetcode.cn/1626927345-DZmfxB-PascalTriangleAnimated2.gif&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: numRows = 5
输出: [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: numRows = 1
输出: [[1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= numRows &amp;lt;= 30&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# array[i][j] = array[i-1][j-1] + array[i-1][j]，转移方程

def solution(n:int):
    # 左侧添加一个0保护
    array = [[0] * (n + 1) for _ in range(n)]
    array[0][1] = 1

    for i in range(1, n):
        for j in range(1, n + 1):
            array[i][j] = array[i - 1][j - 1] + array[i - 1][j]

    ans = []
    # 每行只取有效部分，利用行下标
    for i in range(n):
        ans.append(array[i][1:i + 2])

    return ans


if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    print(solution(n))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;打家劫舍&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;198. 打家劫舍&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;你是一个专业的小偷，计划偷窃沿街的房屋。每间房内都藏有一定的现金，影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统， &lt;strong&gt;如果两间相邻的房屋在同一晚上被小偷闯入，系统会自动报警&lt;/strong&gt; 。&lt;/p&gt;
&lt;p&gt;给定一个代表每个房屋存放金额的非负整数数组，计算你  &lt;strong&gt;不触动警报装置的情况下&lt;/strong&gt;  ，一夜之内能够偷窃到的最高金额。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：[1,2,3,1]
输出：4
解释：偷窃 1 号房屋 (金额 = 1) ，然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：[2,7,9,3,1]
输出：12
解释：偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9)，接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= nums[i] &amp;lt;= 400&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 定义dp[i]为到i位置可以抢劫的最大金额，他就等于max(dp[i-1],dp[i-2]+nums[i])，意思是要么不偷，维持dp[i-1]的方案；要么偷，从前面选不会报警的最大金额方案

def solution(nums:list):
    n = len(nums)
    if not nums:
        return 0
    if n == 1:
        return nums[0]
    dp = [0]*(n+1)
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2,n):
        dp[i] = max(dp[i-2]+nums[i],dp[i-1])
    return dp[n-1]

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;注意这里dp[i]的含义，我们不需要遍历前面最大的dp，只要看dp[i-2]就行&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;完全平方数&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;279. 完全平方数&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数 &lt;code&gt;n&lt;/code&gt; ，返回 &lt;em&gt;和为 &lt;code&gt;n&lt;/code&gt; 的完全平方数的最少数量&lt;/em&gt; 。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;完全平方数&lt;/strong&gt;  是一个整数，其值等于另一个整数的平方；换句话说，其值等于一个整数自乘的积。例如，&lt;code&gt;1&lt;/code&gt;、&lt;code&gt;4&lt;/code&gt;、&lt;code&gt;9&lt;/code&gt; 和 &lt;code&gt;16&lt;/code&gt; 都是完全平方数，而 &lt;code&gt;3&lt;/code&gt; 和 &lt;code&gt;11&lt;/code&gt; 不是。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 12
输出：3 
解释：12 = 4 + 4 + 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：n = 13
输出：2
解释：13 = 4 + 9
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 我们令dp[i]为凑出数字i所需的最小完全平方数个数，则转移为min(dp[i],dp[i-j*j]+1)，也就是说这是一道选或不选的dp

def solution(n: int) -&amp;gt; int:
    dp = [float(&quot;inf&quot;)] * (n + 1)
    dp[0] = 0

    for i in range(1, n + 1):
        j = 1
        while j * j &amp;lt;= i:
            dp[i] = min(dp[i], dp[i - j * j] + 1)
            j += 1

    return dp[n]

if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    print(solution(n))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题好像默认一定能选出来，否则的话输出dp[n]还需要加一个无穷保护。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;零钱兑换&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;322. 零钱兑换&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;coins&lt;/code&gt; ，表示不同面额的硬币；以及一个整数 &lt;code&gt;amount&lt;/code&gt; ，表示总金额。&lt;/p&gt;
&lt;p&gt;计算并返回可以凑成总金额所需的  &lt;strong&gt;最少的硬币个数&lt;/strong&gt;  。如果没有任何一种硬币组合能组成总金额，返回 &lt;code&gt;-1&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;你可以认为每种硬币的数量是无限的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：coins = [1, 2, 5], amount = 11
输出：3 
解释：11 = 5 + 5 + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：coins = [2], amount = 3
输出：-1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：coins = [1], amount = 0
输出：0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= coins.length &amp;lt;= 12&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= coins[i] &amp;lt;= 2^31 - 1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= amount &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 依旧选还是不选，然后最小问题，和上一题很相似，属于完全背包问题

def solution(coins:list,amount:int)-&amp;gt;int:
    # dp[i]为金额i需要的最少coin数，转移方程为min(dp[i],dp[i-c]+1)
    dp = [float(&apos;inf&apos;)] * (amount+1)
    dp[0] = 0
    for i in range(amount+1):
        # 硬币无限，可以重复遍历
        for coin in coins:
            if coin&amp;lt;=i:
                dp[i] = min(dp[i],dp[i-coin]+1)
    return dp[amount] if dp[amount]!=float(&apos;inf&apos;) else -1

if __name__ == &quot;__main__&quot;:
    coins = list(map(int,input().strip().split(&apos;,&apos;)))
    amount = int(input().strip())
    print(solution(coins,amount))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;同样是完全背包问题，易错点是忘了让dp[0] = 0了。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;单词拆分&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;139. 单词拆分&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个字符串 &lt;code&gt;s&lt;/code&gt; 和一个字符串列表 &lt;code&gt;wordDict&lt;/code&gt; 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 &lt;code&gt;s&lt;/code&gt; 则返回 &lt;code&gt;true&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意：&lt;/strong&gt; 不要求字典中出现的单词全部都使用，并且字典中的单词可以重复使用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;leetcode&quot;, wordDict = [&quot;leet&quot;, &quot;code&quot;]
输出: true
解释: 返回 true 因为 &quot;leetcode&quot; 可以由 &quot;leet&quot; 和 &quot;code&quot; 拼接成。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;applepenapple&quot;, wordDict = [&quot;apple&quot;, &quot;pen&quot;]
输出: true
解释: 返回 true 因为 &quot;applepenapple&quot; 可以由 &quot;apple&quot; &quot;pen&quot; &quot;apple&quot; 拼接成。
     注意，你可以重复使用字典中的单词。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: s = &quot;catsandog&quot;, wordDict = [&quot;cats&quot;, &quot;dog&quot;, &quot;sand&quot;, &quot;and&quot;, &quot;cat&quot;]
输出: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= s.length &amp;lt;= 300&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= wordDict.length &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= wordDict[i].length &amp;lt;= 20&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 和 &lt;code&gt;wordDict[i]&lt;/code&gt; 仅由小写英文字母组成&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wordDict&lt;/code&gt; 中的所有字符串  &lt;strong&gt;互不相同&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 依旧是无限资源凑目标，其实还是完全背包，注意设置dp为i个字母的时候可否凑成即可

import ast

def solution(s:str,wordDict:list[str])-&amp;gt;bool:
    n = len(s)
    dp = [False] * (n+1)
    # 0个字母的时候，一定可以凑成
    dp[0] = True
    for i in range(1, n+1):
        for word in wordDict:
            if i&amp;gt;=len(word) and s[i-len(word):i] == word:
                # 完美背包要有防止覆盖的手段
                dp[i] = dp[i] or dp[i-len(word)]
                # 优化：如果有一种能凑出来了，就不用看了
                if dp[i]:
                    break

    return dp[n]

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    wordDict = ast.literal_eval(input().strip())
    print(solution(s,wordDict))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;注意完全背包防止覆盖，最大最小问题往往会有min、max里面加上本身，T or F问题则是or一下本身。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;最长递增子序列&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;300. 最长递增子序列&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; ，找到其中最长严格递增子序列的长度。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;子序列&lt;/strong&gt;  是由数组派生而来的序列，删除（或不删除）数组中的元素而不改变其余元素的顺序。例如，&lt;code&gt;[3,6,2,7]&lt;/code&gt; 是数组 &lt;code&gt;[0,3,1,6,2,2,7]&lt;/code&gt; 的子序列。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [10,9,2,5,3,7,101,18]
输出：4
解释：最长递增子序列是 [2,3,7,101]，因此长度为 4 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [0,1,0,3,2,3]
输出：4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [7,7,7,7,7,7,7]
输出：1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 2500&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^4 &amp;lt;= nums[i] &amp;lt;= 10^4&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你能将算法的时间复杂度降低到 &lt;code&gt;O(n log(n))&lt;/code&gt; 吗?&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 最长递增子序列也是经典的dp，我们定义dp[i]为到第i位的最长递增子序列，则从前面找所有dp，如果满足该位置的数字比目前为止小，就和dp[i]本身比一下大小，保留最大的

def solution(nums:list)-&amp;gt;int:
    n = len(nums)
    dp = [1] * n
    for i in range(1,n):
        for j in range(0,i):
            if nums[j]&amp;lt;nums[i]:
                dp[i] = max(dp[i],dp[j]+1)
    return max(dp)

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;本题的dp有点变化，首先是全部要初始化成1，因为至少也有本身数字这一个序列，所以也不需要初始化一个边界兜底。然后就是，最后要求的不是到最后一位的最长递增子序列，所以应该在dp里面找最大值。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;乘积最大子数组&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;152. 乘积最大子数组&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; ，请你找出数组中乘积最大的非空连续 子数组（该子数组中至少包含一个数字），并返回该子数组所对应的乘积。&lt;/p&gt;
&lt;p&gt;测试用例的答案是一个  &lt;strong&gt;32-位&lt;/strong&gt;  整数。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;请注意&lt;/strong&gt; ，一个只包含一个元素的数组的乘积是这个元素的值。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 2 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10 &amp;lt;= nums[i] &amp;lt;= 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 的任何子数组的乘积都  &lt;strong&gt;保证&lt;/strong&gt;  是一个  &lt;strong&gt;32-位&lt;/strong&gt;  整数&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 必须要连续，那只要看上一位就行了。
# 但是本题有个跟加法不同的毒点，就是两个负数可能会翻转最小为最大，所以要用两个状态记录，一个max_prod一个min_prod。然后用max(x,max_prod*x,min_prod*x)来选择ans，并更新最大最小值

def solution(nums: list) -&amp;gt; int:
    max_prod = nums[0]
    min_prod = nums[0]
    ans = nums[0]

    for i in range(1, len(nums)):
        x = nums[i]

        candidates = (x, max_prod * x, min_prod * x)
        max_prod = max(candidates)
        min_prod = min(candidates)

        ans = max(ans, max_prod)

    return ans


if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;本题考虑负数的翻转，需要额外维护变量，比较恶心&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;分割等和子集&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;416. 分割等和子集&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个  &lt;strong&gt;只包含正整数&lt;/strong&gt;  的  &lt;strong&gt;非空&lt;/strong&gt;  数组 &lt;code&gt;nums&lt;/code&gt; 。请你判断是否可以将这个数组分割成两个子集，使得两个子集的元素和相等。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,5,11,5]
输出：true
解释：数组可以分割成 [1, 5, 5] 和 [11] 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,2,3,5]
输出：false
解释：数组不能分割成两个元素和相等的子集。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 200&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums[i] &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 1 · 递归（时间超限）&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 转化一下，是否有子数组和为total的一半，每个数字选或者不选

def solution(nums:list)-&amp;gt;bool:
    total = sum(nums)
    if total % 2 != 0:
        return False
    target = total // 2
    n = len(nums)
    # 递归函数判断能不能达到target
    def dfs(i,target)-&amp;gt;bool:
        if target == 0:
            return True
        if i &amp;gt;= n or target&amp;lt;0:
            return False
        # 开始递归
        return dfs(i+1,target-nums[i]) or dfs(i+1,target)
    
    return dfs(0,target)

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 0/1背包&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 选或不选问题，其实可以用标准的0/1背包问题解决

def solution(nums: list) -&amp;gt; bool:
    total = sum(nums)
    if total % 2 != 0:
        return False

    target = total // 2
    dp = [False] * (target + 1)
    dp[0] = True

    # 0/1 背包：每个数只能用一次，所以要倒序遍历
    # dp[i]代表能否凑出i
    for num in nums:
        # 假设拿到了num，我们看看以前的选择能不能凑出来，如果能凑出j-num，那肯定就能凑出j
        # 与完全背包相对的，这里是倒序遍历。目的是为了保证num只用一次（只看没更新过的dp）
        # 而且注意范围，因为要看j-num要保护边界
        for j in range(target, num - 1, -1):
            dp[j] = dp[j] or dp[j - num]

    return dp[target]


if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这是一道0/1背包，注意和完全背包区分来学&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;最长有效括号&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;32. 最长有效括号&lt;/h4&gt;
&lt;p&gt;难度：困难&lt;/p&gt;
&lt;p&gt;给你一个只包含 &lt;code&gt;&apos;(&apos;&lt;/code&gt; 和 &lt;code&gt;&apos;)&apos;&lt;/code&gt; 的字符串，找出最长有效（格式正确且连续）括号 子串 的长度。&lt;/p&gt;
&lt;p&gt;左右括号匹配，即每个左括号都有对应的右括号将其闭合的字符串是格式正确的，比如 &lt;code&gt;&quot;(()())&quot;&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;(()&quot;
输出：2
解释：最长有效括号子串是 &quot;()&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;)()())&quot;
输出：4
解释：最长有效括号子串是 &quot;()()&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;&quot;
输出：0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= s.length &amp;lt;= 3 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s[i]&lt;/code&gt; 为 &lt;code&gt;&apos;(&apos;&lt;/code&gt; 或 &lt;code&gt;&apos;)&apos;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 我们让dp[i]为以 i 结尾的最长有效括号长度

def solution(s: str) -&amp;gt; int:
    n = len(s)
    dp = [0] * n
    ans = 0

    # dp[i] 表示：以 s[i] 结尾的最长有效括号长度
    for i in range(1, n):
        if s[i] == &apos;)&apos;:
            # 情况1：...()
            if s[i - 1] == &apos;(&apos;:
                dp[i] = (dp[i - 2] if i &amp;gt;= 2 else 0) + 2

            # 情况2：...))
            else:
                # 去找和当前这个 &apos;)&apos; 匹配的 &apos;(&apos;
                j = i - dp[i - 1] - 1
                if j &amp;gt;= 0 and s[j] == &apos;(&apos;:
                    dp[i] = dp[i - 1] + 2
                    # 把前面可能连着的有效括号也接上
                    if j - 1 &amp;gt;= 0:
                        dp[i] += dp[j - 1]

            ans = max(ans, dp[i])

    return ans

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题算是dp的大boss了。。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;不同路径&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;62. 不同路径&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;一个机器人位于一个 &lt;code&gt;m x n&lt;/code&gt; 网格的左上角 （起始点在下图中标记为 “Start” ）。&lt;/p&gt;
&lt;p&gt;机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角（在下图中标记为 “Finish” ）。&lt;/p&gt;
&lt;p&gt;问总共有多少条不同的路径？&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pic.leetcode.cn/1697422740-adxmsI-image.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：m = 3, n = 7
输出：28
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：m = 3, n = 2
输出：3
解释：
从左上角开始，总共有 3 条路径可以到达右下角。
1. 向右 -&amp;gt; 向下 -&amp;gt; 向下
2. 向下 -&amp;gt; 向下 -&amp;gt; 向右
3. 向下 -&amp;gt; 向右 -&amp;gt; 向下
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：m = 7, n = 3
输出：28
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 4：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：m = 3, n = 3
输出：6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;题目数据保证答案小于等于 &lt;code&gt;2 * 10^9&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 多维dp，因为只能向下或向右，我们先把第一行、第一列初始化
# dp[i][j]为到达(i,j)共有多少不同的路径

def solution(m:int,n:int)-&amp;gt;int:
    dp = [[0]*n for row in range(m)]
    # 初始化边界
    for j in range(n):
        dp[0][j] = 1
    for i in range(m):
        dp[i][0] = 1

    # 更新dp
    for i in range(1,m):
        for j in range(1,n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    
    return dp[m-1][n-1]

if __name__ == &quot;__main__&quot;:
    m = int(input().strip())
    n = int(input().strip())
    print(solution(m,n))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;标准的多维dp&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;最小路径和&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;64. 最小路径和&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个包含非负整数的 &lt;code&gt;_m_ x _n_&lt;/code&gt; 网格 &lt;code&gt;grid&lt;/code&gt; ，请找出一条从左上角到右下角的路径，使得路径上的数字总和为最小。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;说明：&lt;/strong&gt; 每次只能向下或者向右移动一步。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://assets.leetcode.com/uploads/2020/11/05/minpath.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：grid = [[1,3,1],[1,5,1],[4,2,1]]
输出：7
解释：因为路径 1→3→1→1→1 的总和最小。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：grid = [[1,2,3],[4,5,6]]
输出：12
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;m == grid.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n == grid[i].length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= m, n &amp;lt;= 200&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= grid[i][j] &amp;lt;= 200&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 还是多维dp，同样需要初始化边界，用dp[i][j]表示到(i,j)最小的路径

import ast

def solution(grid:list[list[int]])-&amp;gt;int:
    m = len(grid)
    n = len(grid[0])
    dp = [[0]*n for row in range(m)]
    # 初始化边界
    dp[0][0] = grid[0][0]
    for j in range(1,n):
        dp[0][j] = dp[0][j-1] + grid[0][j]
    for i in range(1,m):
        dp[i][0] = dp[i-1][0] + grid[i][0]
    
    # 开始dp
    for i in range(1,m):
        for j in range(1,n):
            dp[i][j] = min(dp[i-1][j],dp[i][j-1]) + grid[i][j]
    
    return dp[m-1][n-1]


if __name__ == &quot;__main__&quot;:
    grid = ast.literal_eval(input().strip())
    print(solution(grid))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;没啥说的，还是二维dp&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;最长回文子串&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;5. 最长回文子串&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你一个字符串 &lt;code&gt;s&lt;/code&gt;，找到 &lt;code&gt;s&lt;/code&gt; 中最长的 回文 子串。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;babad&quot;
输出：&quot;bab&quot;
解释：&quot;aba&quot; 同样是符合题意的答案。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：s = &quot;cbbd&quot;
输出：&quot;bb&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= s.length &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; 仅由数字和英文字母组成&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解 1 · 二维dp&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 最直接的做法是二重循环拿所有子串，用辅助函数判断是否是回文，然后维持一个最大长度，性能比较差
# 一个起点一个终点，可用二维动态规划。dp[i][j]表示s[i:j+1]是否是回文

def solution(s: str) -&amp;gt; str:
    n = len(s)
    dp = [[False] * n for _ in range(n)]

    start = 0
    max_len = 1

    # 单个字符一定是回文
    for i in range(n):
        dp[i][i] = True

    # 按子串长度递增枚举
    for length in range(2, n + 1):
        # 一定长度的i、j区间移动更新dp
        for i in range(n - length + 1):
            j = i + length - 1

            if s[i] != s[j]:
                dp[i][j] = False
            else:
                # 长度为 2 或 3 时，只要两端相等就是回文
                if length &amp;lt;= 3:
                    dp[i][j] = True
                else:
                    dp[i][j] = dp[i + 1][j - 1]

            if dp[i][j] and length &amp;gt; max_len:
                start = i
                max_len = length

    return s[start:start + max_len]

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 题解 2 · 中心拓展&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 中心拓展法是不需要额外空间的解法，依据就是回文串一定有一个中心，从中心往两边扩

def solution(s: str) -&amp;gt; str:
    if not s:
        return &quot;&quot;

    start = 0
    end = 0

    def expand(left: int, right: int) -&amp;gt; tuple[int, int]:
        while left &amp;gt;= 0 and right &amp;lt; len(s) and s[left] == s[right]:
            left -= 1
            right += 1
        return left + 1, right - 1

    for i in range(len(s)):
        # 以一个字符为中心，处理奇数长度回文
        l1, r1 = expand(i, i)
        # 以两个字符中间为中心，处理偶数长度回文
        l2, r2 = expand(i, i + 1)

        if r1 - l1 &amp;gt; end - start:
            start, end = l1, r1
        if r2 - l2 &amp;gt; end - start:
            start, end = l2, r2

    return s[start:end + 1]


if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;最长公共子序列&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;1143. 最长公共子序列&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定两个字符串 &lt;code&gt;text1&lt;/code&gt; 和 &lt;code&gt;text2&lt;/code&gt;，返回这两个字符串的最长  &lt;strong&gt;公共子序列&lt;/strong&gt;  的长度。如果不存在  &lt;strong&gt;公共子序列&lt;/strong&gt;  ，返回 &lt;code&gt;0&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;一个字符串的  &lt;strong&gt;子序列&lt;/strong&gt;  是指这样一个新的字符串：它是由原字符串在不改变字符的相对顺序的情况下删除某些字符（也可以不删除任何字符）后组成的新字符串。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;例如，&lt;code&gt;&quot;ace&quot;&lt;/code&gt; 是 &lt;code&gt;&quot;abcde&quot;&lt;/code&gt; 的子序列，但 &lt;code&gt;&quot;aec&quot;&lt;/code&gt; 不是 &lt;code&gt;&quot;abcde&quot;&lt;/code&gt; 的子序列。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两个字符串的  &lt;strong&gt;公共子序列&lt;/strong&gt;  是这两个字符串所共同拥有的子序列。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：text1 = &quot;abcde&quot;, text2 = &quot;ace&quot; 
输出：3  
解释：最长公共子序列是 &quot;ace&quot; ，它的长度为 3 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：text1 = &quot;abc&quot;, text2 = &quot;abc&quot;
输出：3
解释：最长公共子序列是 &quot;abc&quot; ，它的长度为 3 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：text1 = &quot;abc&quot;, text2 = &quot;def&quot;
输出：0
解释：两个字符串没有公共子序列，返回 0 。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= text1.length, text2.length &amp;lt;= 1000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text1&lt;/code&gt; 和 &lt;code&gt;text2&lt;/code&gt; 仅由小写英文字符组成。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 好难但是经典，dp[i][j] 表示 text1 前 i 个字符 和 text2 前 j 个字符 的最长公共子序列长度。

def solution(text1: str, text2: str) -&amp;gt; int:
    m, n = len(text1),len(text2)
    dp = [[0] * (n+1) for _ in range(m+1)]
    for i in range(1,m+1):
        for j in range(1,n+1):
            # Case1、字母相同，直接加到公共子序列
            if text1[i-1] == text2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                # 如果不相等，看看谁前进一格能让dp更大，要么扔掉 text1 这个字符，看 dp[i-1][j]；要么扔掉 text2 这个字符，看 dp[i][j-1]
                dp[i][j] = max(dp[i-1][j],dp[i][j-1])
    return dp[m][n]

if __name__ == &quot;__main__&quot;:
    text1 = input().strip()
    text2 = input().strip()
    print(solution(text1,text2))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;编辑距离&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;72. 编辑距离&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给你两个单词 &lt;code&gt;word1&lt;/code&gt; 和 &lt;code&gt;word2&lt;/code&gt;， &lt;em&gt;请返回将 &lt;code&gt;word1&lt;/code&gt; 转换成 &lt;code&gt;word2&lt;/code&gt; 所使用的最少操作数&lt;/em&gt;  。&lt;/p&gt;
&lt;p&gt;你可以对一个单词进行如下三种操作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;插入一个字符&lt;/li&gt;
&lt;li&gt;删除一个字符&lt;/li&gt;
&lt;li&gt;替换一个字符&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：word1 = &quot;horse&quot;, word2 = &quot;ros&quot;
输出：3
解释：
horse -&amp;gt; rorse (将 &apos;h&apos; 替换为 &apos;r&apos;)
rorse -&amp;gt; rose (删除 &apos;r&apos;)
rose -&amp;gt; ros (删除 &apos;e&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：word1 = &quot;intention&quot;, word2 = &quot;execution&quot;
输出：5
解释：
intention -&amp;gt; inention (删除 &apos;t&apos;)
inention -&amp;gt; enention (将 &apos;i&apos; 替换为 &apos;e&apos;)
enention -&amp;gt; exention (将 &apos;n&apos; 替换为 &apos;x&apos;)
exention -&amp;gt; exection (将 &apos;n&apos; 替换为 &apos;c&apos;)
exection -&amp;gt; execution (插入 &apos;u&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= word1.length, word2.length &amp;lt;= 500&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;word1&lt;/code&gt; 和 &lt;code&gt;word2&lt;/code&gt; 由小写英文字母组成&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 又是需要自行转化为dp的问题，我们要活用dp让目前只要看当前i、j位，然后遍历完成。

def solution(word1: str, word2: str) -&amp;gt; int:
    m, n = len(word1), len(word2)
    # dp[i][j] 代表 word1 中前 i 个字符，变换到 word2 中前 j 个字符，最短需要操作的次数
    dp = [[0] * (n+1) for _ in range(m+1)]
    # 基础情况
    for i in range(1,m+1):
        dp[i][0] = i
    for j in range(1,n+1):
        dp[0][j] = j

    for i in range(1,m+1):
        for j in range(1,n+1):
            # 当前位相等，不用加编辑距离
            if word1[i - 1] == word2[j - 1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                # 下面三种情况，分别代表插入、删除、替换，取最小的操作数
                dp[i][j] = min(
                    dp[i-1][j] + 1,
                    dp[i][j-1] + 1,
                    dp[i-1][j-1] + 1
                )
    return dp[m][n]

if __name__ ==&quot;__main__&quot;:
    word1 = input().strip()
    word2 = input().strip()
    print(solution(word1,word2))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;上面两题都是把两个字符串当做二维DP，天然就是“第一个串处理到 i，第二个串处理到 j”，算是比较经典&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;只出现一次的数字&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;136. 只出现一次的数字&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给你一个  &lt;strong&gt;非空&lt;/strong&gt;  整数数组 &lt;code&gt;nums&lt;/code&gt; ，除了某个元素只出现一次以外，其余每个元素均出现两次。找出那个只出现了一次的元素。&lt;/p&gt;
&lt;p&gt;你必须设计并实现线性时间复杂度的算法来解决此问题，且该算法只使用常量额外空间。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1 ：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入：&lt;/strong&gt; nums = [2,2,1]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; 1&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 2 ：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入：&lt;/strong&gt; nums = [4,1,2,1,2]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; 4&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 3 ：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输入：&lt;/strong&gt; nums = [1]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出：&lt;/strong&gt; 1&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 3 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-3 * 10^4 &amp;lt;= nums[i] &amp;lt;= 3 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;除了某个元素只出现一次以外，其余每个元素均出现两次。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 如果我没记错的话，应该这题是使用位结论。自己异或自己是0，然后0异或所以东西都是不变的。所有两次的数字都湮灭了

def solution(nums:list)-&amp;gt;int:
    result = 0
    for num in nums:
        result ^= num
    return result

if __name__ == &quot;__main__&quot;:
    nums = list(map(int,input().strip().split(&apos;,&apos;)))
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;多数元素&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;169. 多数元素&lt;/h4&gt;
&lt;p&gt;难度：简单&lt;/p&gt;
&lt;p&gt;给定一个大小为 &lt;code&gt;n&lt;/code&gt; 的数组 &lt;code&gt;nums&lt;/code&gt; ，返回其中的多数元素。多数元素是指在数组中出现次数  &lt;strong&gt;大于&lt;/strong&gt;  &lt;code&gt;⌊ n/2 ⌋&lt;/code&gt; 的元素。&lt;/p&gt;
&lt;p&gt;你可以假设数组是非空的，并且给定的数组总是存在多数元素。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,2,3]
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [2,2,1,1,1,2,2]
输出：2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n == nums.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 5 * 10^4&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-10^9 &amp;lt;= nums[i] &amp;lt;= 10^9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;输入保证数组中一定有一个多数元素。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt; 尝试设计时间复杂度为 O(n)、空间复杂度为 O(1) 的算法解决此问题。&lt;/p&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 其实这题用python的Counter计数再查找就是打败100%了，但是貌似希望我们使用的是摩尔投票法来求多数元素

import ast


def solution(nums: list[int]) -&amp;gt; int:
    candidate = None
    count = 0

    for num in nums:
        if count == 0:
            candidate = num
            count = 1
        elif num == candidate:
            count += 1
        else:
            count -= 1

    return candidate


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;这题的幽默之处在于你用Counter硬记数反而时间还更快了。。这就是底层优化的力量么&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;颜色分类&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;75. 颜色分类&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个包含红色、白色和蓝色、共 &lt;code&gt;n&lt;/code&gt; 个元素的数组 &lt;code&gt;nums&lt;/code&gt; ， &lt;strong&gt;&lt;a href=&quot;https://baike.baidu.com/item/%E5%8E%9F%E5%9C%B0%E7%AE%97%E6%B3%95&quot;&gt;原地&lt;/a&gt;&lt;/strong&gt;  对它们进行排序，使得相同颜色的元素相邻，并按照红色、白色、蓝色顺序排列。&lt;/p&gt;
&lt;p&gt;我们使用整数 &lt;code&gt;0&lt;/code&gt;、 &lt;code&gt;1&lt;/code&gt; 和 &lt;code&gt;2&lt;/code&gt; 分别表示红色、白色和蓝色。&lt;/p&gt;
&lt;p&gt;必须在不使用库内置的 sort 函数的情况下解决这个问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [2,0,2,1,1,0]
输出：[0,0,1,1,2,2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [2,0,1]
输出：[0,1,2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n == nums.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 300&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums[i]&lt;/code&gt; 为 &lt;code&gt;0&lt;/code&gt;、&lt;code&gt;1&lt;/code&gt; 或 &lt;code&gt;2&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你能想出一个仅使用常数空间的一趟扫描算法吗？&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题本质上，就是直接排序呗，不过不让用sort，那就插入排序也行，或者其他排序。
# 但是这一题其实没必要真的排序，只需要分区，这是经典的三指针/荷兰国旗问题。
import ast


def solution(nums: list[int]) -&amp;gt; list[int]:
    low = 0
    mid = 0
    high = len(nums) - 1

    # mid扫描未知区域，为0和low区换，为2和high区换
    while mid &amp;lt;= high:
        if nums[mid] == 0:
            nums[low], nums[mid] = nums[mid], nums[low]
            low += 1
            mid += 1
        elif nums[mid] == 1:
            mid += 1
        else:  # nums[mid] == 2
            nums[mid], nums[high] = nums[high], nums[mid]
            high -= 1

    return nums


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;下一个排列&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;31. 下一个排列&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;整数数组的一个  &lt;strong&gt;排列&lt;/strong&gt;   就是将其所有成员以序列或线性顺序排列。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;例如，&lt;code&gt;arr = [1,2,3]&lt;/code&gt; ，以下这些都可以视作 &lt;code&gt;arr&lt;/code&gt; 的排列：&lt;code&gt;[1,2,3]&lt;/code&gt;、&lt;code&gt;[1,3,2]&lt;/code&gt;、&lt;code&gt;[3,1,2]&lt;/code&gt;、&lt;code&gt;[2,3,1]&lt;/code&gt; 。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;整数数组的  &lt;strong&gt;下一个排列&lt;/strong&gt;  是指其整数的下一个字典序更大的排列。更正式地，如果数组的所有排列根据其字典顺序从小到大排列在一个容器中，那么数组的  &lt;strong&gt;下一个排列&lt;/strong&gt;  就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列，那么这个数组必须重排为字典序最小的排列（即，其元素按升序排列）。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;例如，&lt;code&gt;arr = [1,2,3]&lt;/code&gt; 的下一个排列是 &lt;code&gt;[1,3,2]&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;类似地，&lt;code&gt;arr = [2,3,1]&lt;/code&gt; 的下一个排列是 &lt;code&gt;[3,1,2]&lt;/code&gt; 。&lt;/li&gt;
&lt;li&gt;而 &lt;code&gt;arr = [3,2,1]&lt;/code&gt; 的下一个排列是 &lt;code&gt;[1,2,3]&lt;/code&gt; ，因为 &lt;code&gt;[3,2,1]&lt;/code&gt; 不存在一个字典序更大的排列。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;给你一个整数数组 &lt;code&gt;nums&lt;/code&gt; ，找出 &lt;code&gt;nums&lt;/code&gt; 的下一个排列。&lt;/p&gt;
&lt;p&gt;必须  &lt;strong&gt;&lt;a href=&quot;https://baike.baidu.com/item/%E5%8E%9F%E5%9C%B0%E7%AE%97%E6%B3%95&quot;&gt;原地&lt;/a&gt;&lt;/strong&gt;  修改，只允许使用额外常数空间。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,2,3]
输出：[1,3,2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,2,1]
输出：[1,2,3]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,1,5]
输出：[1,5,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums.length &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 &amp;lt;= nums[i] &amp;lt;= 100&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这一题是找刚好能让排列变大一点的下一个序列
import ast


def solution(nums: list[int]) -&amp;gt; list[int]:
    n = len(nums)

    # 1. 从右往左找第一个下降的位置 i
    i = n - 2
    while i &amp;gt;= 0 and nums[i] &amp;gt;= nums[i + 1]:
        i -= 1

    # 2. 如果找到了，从右往左找第一个比 nums[i] 大的数 j，交换
    if i &amp;gt;= 0:
        j = n - 1
        while nums[j] &amp;lt;= nums[i]:
            j -= 1
        nums[i], nums[j] = nums[j], nums[i]

    # 3. 把 i+1 后面的部分反转，变成最小升序
    left, right = i + 1, n - 1
    while left &amp;lt; right:
        nums[left], nums[right] = nums[right], nums[left]
        left += 1
        right -= 1

    return nums


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 反思&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;通常叫下一个排列算法，还是直接看板子理解意思即可。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h1&gt;寻找重复数&lt;/h1&gt;
&lt;h2&gt;1. 题面&lt;/h2&gt;
&lt;h4&gt;287. 寻找重复数&lt;/h4&gt;
&lt;p&gt;难度：中等&lt;/p&gt;
&lt;p&gt;给定一个包含 &lt;code&gt;n + 1&lt;/code&gt; 个整数的数组 &lt;code&gt;nums&lt;/code&gt; ，其数字都在 &lt;code&gt;[1, n]&lt;/code&gt; 范围内（包括 &lt;code&gt;1&lt;/code&gt; 和 &lt;code&gt;n&lt;/code&gt;），可知至少存在一个重复的整数。&lt;/p&gt;
&lt;p&gt;假设 &lt;code&gt;nums&lt;/code&gt; 只有  &lt;strong&gt;一个重复的整数&lt;/strong&gt;  ，返回  &lt;strong&gt;这个重复的数&lt;/strong&gt;  。&lt;/p&gt;
&lt;p&gt;你设计的解决方案必须  &lt;strong&gt;不修改&lt;/strong&gt;  数组 &lt;code&gt;nums&lt;/code&gt; 且只用常量级 &lt;code&gt;O(1)&lt;/code&gt; 的额外空间。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;示例 1：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [1,3,4,2,2]
输出：2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 2：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,1,3,4,2]
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;示例 3 :&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：nums = [3,3,3,3,3]
输出：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提示：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= n &amp;lt;= 10^5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums.length == n + 1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;= nums[i] &amp;lt;= n&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nums&lt;/code&gt; 中  &lt;strong&gt;只有一个整数&lt;/strong&gt;  出现  &lt;strong&gt;两次或多次&lt;/strong&gt;  ，其余整数均只出现  &lt;strong&gt;一次&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;进阶：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如何证明 &lt;code&gt;nums&lt;/code&gt; 中至少存在一个重复的数字?&lt;/li&gt;
&lt;li&gt;你可以设计一个线性级时间复杂度 &lt;code&gt;O(n)&lt;/code&gt; 的解决方案吗？&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 题解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 这题的技巧是，用数组映射成链表，nums[i]就是i指向的下一个，所以就变成了链表判环问题了。

import ast


def solution(nums: list[int]) -&amp;gt; int:
    slow = nums[0]
    fast = nums[0]

    # 第一步：快慢指针相遇
    while True:
        slow = nums[slow]
        fast = nums[nums[fast]]
        if slow == fast:
            break

    # 第二步：从起点和相遇点同时走，环入口就是重复数
    slow = nums[0]
    while slow != fast:
        slow = nums[slow]
        fast = nums[fast]

    return slow


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    print(solution(nums))
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>FastAPI 验证与运行：Testing、CLI、Uvicorn 与 Workers</title><link>https://owen571.top/posts/study/fastapi/10-fastapi-%E6%B5%8B%E8%AF%95-cli-uvicorn-%E4%B8%8E%E9%83%A8%E7%BD%B2%E7%9B%B4%E8%A7%89/</link><guid isPermaLink="true">https://owen571.top/posts/study/fastapi/10-fastapi-%E6%B5%8B%E8%AF%95-cli-uvicorn-%E4%B8%8E%E9%83%A8%E7%BD%B2%E7%9B%B4%E8%A7%89/</guid><description>把测试、调试、fastapi CLI、uvicorn、手动运行和 workers 收到一起，形成一条更完整的“本地开发到部署”的路径。</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;如果前面的内容是在搭接口本身，这一篇就是在补“怎么确认它是对的，以及怎么把它跑起来”。&lt;/p&gt;
&lt;h2&gt;1. &lt;code&gt;TestClient&lt;/code&gt;：为什么测试可以写成普通 &lt;code&gt;def&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;官方测试页给的最小例子非常清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get(&quot;/&quot;)
async def read_main():
    return {&quot;msg&quot;: &quot;Hello World&quot;}


client = TestClient(app)


def test_read_main():
    response = client.get(&quot;/&quot;)
    assert response.status_code == 200
    assert response.json() == {&quot;msg&quot;: &quot;Hello World&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方特别提醒了两点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;测试函数可以是普通 &lt;code&gt;def&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;client.get()&lt;/code&gt; 也是普通调用，不需要 &lt;code&gt;await&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这让你可以直接用 &lt;code&gt;pytest&lt;/code&gt;，不会一上来就卡在异步测试细节里。&lt;br /&gt;
来源：Testing 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/testing/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/testing/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;2. 测试文件通常怎么放&lt;/h2&gt;
&lt;p&gt;官方示例里常见的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app/
├── __init__.py
├── main.py
└── test_main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样 &lt;code&gt;test_main.py&lt;/code&gt; 可以直接相对导入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from .main import app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果项目结构更大，也可以把测试单独放到 &lt;code&gt;tests/&lt;/code&gt; 目录，但第一次入门时，先把测试贴着应用写更容易理解。&lt;/p&gt;
&lt;h2&gt;3. &lt;code&gt;fastapi dev&lt;/code&gt; 是开发模式&lt;/h2&gt;
&lt;p&gt;开发时，最顺手的仍然是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fastapi dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fastapi dev main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果没传路径，CLI 会尝试自动找应用；如果传了路径，它会按路径推断应用对象。官方也说明了，长期来看更推荐在 &lt;code&gt;pyproject.toml&lt;/code&gt; 里配置 &lt;code&gt;entrypoint&lt;/code&gt;，这样工具链更稳定。&lt;br /&gt;
来源：First Steps / FastAPI CLI 官方页&lt;br /&gt;
&lt;a href=&quot;https://fastapi.tiangolo.com/zh/tutorial/first-steps/&quot;&gt;https://fastapi.tiangolo.com/zh/tutorial/first-steps/&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://fastapi.tiangolo.com/zh/fastapi-cli/&quot;&gt;https://fastapi.tiangolo.com/zh/fastapi-cli/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;4. &lt;code&gt;fastapi run&lt;/code&gt; 是生产模式入口&lt;/h2&gt;
&lt;p&gt;官方 CLI 页明确写到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fastapi dev&lt;/code&gt;：开发模式&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fastapi run&lt;/code&gt;：生产模式&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而且 FastAPI CLI 内部实际就是基于 Uvicorn 来跑应用。&lt;br /&gt;
来源：FastAPI CLI 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/fastapi-cli/&quot;&gt;https://fastapi.tiangolo.com/zh/fastapi-cli/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;也就是说，FastAPI 没有发明一套独立服务器，而是在 CLI 层帮你把 Uvicorn 这类 ASGI 服务器包起来了。&lt;/p&gt;
&lt;h2&gt;5. 手动运行为什么还是要懂 &lt;code&gt;uvicorn main:app&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;官方手动运行页给出的最核心命令是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvicorn main:app --host 0.0.0.0 --port 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个字符串一定要能看懂：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;main&lt;/code&gt;：&lt;code&gt;main.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app&lt;/code&gt;：文件里的 &lt;code&gt;app = FastAPI()&lt;/code&gt; 对象&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它等价于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from main import app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 &lt;code&gt;uvicorn main:app&lt;/code&gt; 的本质，就是告诉 ASGI 服务器“去哪里导入应用”。&lt;br /&gt;
来源：手动运行服务器官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/deployment/manually/&quot;&gt;https://fastapi.tiangolo.com/zh/deployment/manually/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;6. &lt;code&gt;fastapi dev&lt;/code&gt;、&lt;code&gt;fastapi run&lt;/code&gt;、&lt;code&gt;uvicorn main:app&lt;/code&gt; 应该怎么选&lt;/h2&gt;
&lt;p&gt;可以直接按场景分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地开发：&lt;code&gt;fastapi dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;想直接操作底层服务器：&lt;code&gt;uvicorn main:app&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;更接近生产的 FastAPI CLI 启动：&lt;code&gt;fastapi run&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果只是平时写代码，&lt;code&gt;fastapi dev&lt;/code&gt; 最省心。&lt;br /&gt;
如果要真正理解部署、容器和 server process，&lt;code&gt;uvicorn main:app&lt;/code&gt; 一定要看懂。&lt;/p&gt;
&lt;h2&gt;7. &lt;code&gt;--reload&lt;/code&gt; 的位置&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;--reload&lt;/code&gt; 只适合开发阶段。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvicorn main:app --reload
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的意义是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文件变化后自动重启&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它不是生产特性，而是开发便利。&lt;/p&gt;
&lt;h2&gt;8. 为什么还会在代码里写 &lt;code&gt;uvicorn.run(app, ...)&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;你本地 21.md 里记的是这种方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get(&quot;/&quot;)
def root():
    return {&quot;hello world&quot;: &quot;ok&quot;}


if __name__ == &quot;__main__&quot;:
    uvicorn.run(app, host=&quot;0.0.0.0&quot;, port=8000)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类写法最适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地点一下文件直接调试&lt;/li&gt;
&lt;li&gt;临时验证逻辑&lt;/li&gt;
&lt;li&gt;不想切回终端敲命令&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但它更像“调试入口”，不是长期部署约定。&lt;/p&gt;
&lt;h2&gt;9. Workers：多进程是什么时候开始重要&lt;/h2&gt;
&lt;p&gt;官方 Workers 页给出的典型命令是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvicorn main:app --host 0.0.0.0 --port 8080 --workers 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;来源：Workers 官方页 &lt;a href=&quot;https://fastapi.tiangolo.com/zh/deployment/server-workers/&quot;&gt;https://fastapi.tiangolo.com/zh/deployment/server-workers/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;这里的意思是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;启动多个 worker 进程&lt;/li&gt;
&lt;li&gt;用多个进程共同处理请求&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这通常和生产部署有关，而不是入门开发阶段就要立刻开。&lt;/p&gt;
&lt;p&gt;第一次学时更值得先记住的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单进程开发跑通&lt;/li&gt;
&lt;li&gt;理解 CLI 和 Uvicorn 的关系&lt;/li&gt;
&lt;li&gt;再去看 workers、多进程、容器和反向代理&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;10. 从本地到部署，这一层真正连起来的是什么&lt;/h2&gt;
&lt;p&gt;这一篇其实是在把几条原本散落的线收起来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;怎么测试&lt;/li&gt;
&lt;li&gt;怎么调试&lt;/li&gt;
&lt;li&gt;怎么启动&lt;/li&gt;
&lt;li&gt;怎么理解 CLI&lt;/li&gt;
&lt;li&gt;怎么理解 Uvicorn&lt;/li&gt;
&lt;li&gt;怎么理解 workers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当这些线连起来之后，FastAPI 才算真的从“写几个接口”走向“能把服务稳稳跑起来”。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 核心组件 03：Tools</title><link>https://owen571.top/posts/study/langchain/05-tools/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/05-tools/</guid><description>从静态工具到运行时上下文，让模型开始真正“做事”；这一篇也是理解 Agent 为什么不只是一个普通聊天模型的关键。</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;走到这一篇时，前面的模型和消息已经足够支撑“理解输入输出”；现在开始补上行动能力。Tools 是 LangChain 从“会说”走向“会做”的第一步。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;工具能够拓展智能体的能力 —— 让它们获取实时数据、执行代码、查询外部数据库，并在现实场景中采取行动。&lt;/p&gt;
&lt;p&gt;在底层实现中，工具是具备明确定义输入与输出的可调用函数，这些函数会被传递给对话模型 。模型会根据对话上下文判断何时调用工具，以及提供哪些输入参数。&lt;/p&gt;
&lt;h2&gt;2. 创建工具&lt;/h2&gt;
&lt;h3&gt;(1) 基础工具定义&lt;/h3&gt;
&lt;p&gt;创建工具最简单的方式是使用&lt;code&gt;@tool&lt;/code&gt;装饰器。默认情况下，函数的文档字符串会成为工具的描述，帮助模型理解何时使用该工具:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool

@tool
def search_database(query: str, limit: int = 10) -&amp;gt; str:
    &quot;&quot;&quot;Search the customer database for records matching the query.

    Args:
        query: Search terms to look for
        limit: Maximum number of results to return
    &quot;&quot;&quot;
    return f&quot;Found {limit} results for &apos;{query}&apos;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意类型提示是必需的，因为它们定义了工具的输入架构。文档字符串应内容详实且简洁，以帮助模型理解工具的用途。&lt;/p&gt;
&lt;h3&gt;(2) 自定义工具属性&lt;/h3&gt;
&lt;p&gt;我们可以给工具添加一个别名来Override，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@tool(&quot;web_search&quot;)  # Custom name
def search(query: str) -&amp;gt; str:
    &quot;&quot;&quot;Search the web for information.&quot;&quot;&quot;
    return f&quot;Results for: {query}&quot;

print(search.name)  # web_search
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者自定义工具的描述：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@tool(&quot;calculator&quot;, description=&quot;Performs arithmetic calculations. Use this for any math problems.&quot;)
def calc(expression: str) -&amp;gt; str:
    &quot;&quot;&quot;Evaluate mathematical expressions.&quot;&quot;&quot;
    return str(eval(expression))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者再高级一点，用schema定义，同样可以用pydantic、json schema等，这里用pydantic实例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel, Field
from typing import Literal

class WeatherInput(BaseModel):
    &quot;&quot;&quot;Input for weather queries.&quot;&quot;&quot;
    location: str = Field(description=&quot;City name or coordinates&quot;)
    units: Literal[&quot;celsius&quot;, &quot;fahrenheit&quot;] = Field(
        default=&quot;celsius&quot;,
        description=&quot;Temperature unit preference&quot;
    )
    include_forecast: bool = Field(
        default=False,
        description=&quot;Include 5-day forecast&quot;
    )

@tool(args_schema=WeatherInput)
def get_weather(location: str, units: str = &quot;celsius&quot;, include_forecast: bool = False) -&amp;gt; str:
    &quot;&quot;&quot;Get current weather and optional forecast.&quot;&quot;&quot;
    temp = 22 if units == &quot;celsius&quot; else 72
    result = f&quot;Current weather in {location}: {temp} degrees {units[0].upper()}&quot;
    if include_forecast:
        result += &quot;\nNext 5 days: Sunny&quot;
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过注意有两个保留名称，不能用作工具参数，分别是config和runtime。config保留用于内部向工具传递RunnableConfig；runtime保留用于ToolRuntime参数（访问状态、上下文、存储）。&lt;/p&gt;
&lt;h2&gt;3. 访问上下文&lt;/h2&gt;
&lt;p&gt;当工具能够访问运行时信息（如对话历史、用户数据和持久化内存）时，其功能最为强大。本节将介绍如何在工具内部访问和更新这些信息。&lt;/p&gt;
&lt;p&gt;工具可通过ToolRuntime参数访问运行时信息，该参数提供以下能力：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;描述&lt;/th&gt;
&lt;th&gt;用例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;短期内存 —— 当前对话中存在的可变数据（消息、计数器、自定义字段）&lt;/td&gt;
&lt;td&gt;访问对话历史，追踪工具调用次数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;调用时传入的不可变配置（用户 ID、会话信息）&lt;/td&gt;
&lt;td&gt;根据用户身份个性化响应内容&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store&lt;/td&gt;
&lt;td&gt;长期内存 —— 跨对话持久保存的数据&lt;/td&gt;
&lt;td&gt;保存用户偏好设置，维护知识库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream Writer&lt;/td&gt;
&lt;td&gt;在工具执行过程中发送实时更新&lt;/td&gt;
&lt;td&gt;展示耗时操作的执行进度&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config&lt;/td&gt;
&lt;td&gt;执行所用的 RunnableConfig&lt;/td&gt;
&lt;td&gt;访问回调函数、标签和元数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool Call ID&lt;/td&gt;
&lt;td&gt;当前工具调用的唯一标识符&lt;/td&gt;
&lt;td&gt;关联日志与模型调用中的工具调用记录&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre&gt;&lt;code&gt;%%{init: {
  &quot;theme&quot;: &quot;base&quot;,
  &quot;themeVariables&quot;: {
    &quot;fontFamily&quot;: &quot;monospace&quot;
  }
}}%%
graph LR
    subgraph Tool_Runtime_Context
        A[Tool Call] --&amp;gt; B[ToolRuntime]
        B --&amp;gt; C[State Access]
        B --&amp;gt; D[Context Access]
        B --&amp;gt; E[Store Access]
        B --&amp;gt; F[Stream Writer]
    end

    subgraph Available_Resources
        C --&amp;gt; G[Messages]
        C --&amp;gt; H[Custom State]
        D --&amp;gt; I[User ID]
        D --&amp;gt; J[Session Info]
        E --&amp;gt; K[Long-term Memory]
        E --&amp;gt; L[User Preferences]
    end

    subgraph Enhanced_Tool_Capabilities
        M[Context-Aware Tools]
        N[Stateful Tools]
        O[Memory-Enabled Tools]
        P[Streaming Tools]
    end

    G --&amp;gt; M
    H --&amp;gt; N
    I --&amp;gt; M
    J --&amp;gt; M
    K --&amp;gt; O
    L --&amp;gt; O
    F --&amp;gt; P

    classDef trigger fill:#DCFCE7,stroke:#16A34A,stroke-width:2px,color:#14532D;
    classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:2px,color:#1E3A8A;
    classDef output fill:#F3E8FF,stroke:#9333EA,stroke-width:2px,color:#581C87;
    classDef neutral fill:#F3F4F6,stroke:#9CA3AF,stroke-width:2px,color:#374151;

    class A trigger;
    class B,C,D,E,F process;
    class G,H,I,J,K,L neutral;
    class M,N,O,P output;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这张图说明了 &lt;code&gt;ToolRuntime&lt;/code&gt; 在 LangChain 工具体系中的位置。&lt;/p&gt;
&lt;p&gt;一次工具调用发生时，工具拿到的不只是普通参数，还可以通过 &lt;code&gt;ToolRuntime&lt;/code&gt; 访问运行时环境中的多种资源，包括当前会话状态（State）、调用上下文（Context）、长期存储（Store）以及流式输出能力（Stream Writer）。&lt;/p&gt;
&lt;p&gt;正因为工具可以访问这些额外信息，所以它不再只是一个简单函数，而可以演变为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;能感知用户和会话信息的上下文工具；&lt;/li&gt;
&lt;li&gt;能依赖当前对话状态工作的有状态工具；&lt;/li&gt;
&lt;li&gt;能结合长期记忆的记忆增强工具；&lt;/li&gt;
&lt;li&gt;能边执行边输出进度的流式工具。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;换句话说，&lt;code&gt;ToolRuntime&lt;/code&gt; 让工具从“静态函数”升级成了“运行时感知组件”。&lt;/p&gt;
&lt;h3&gt;(1) State Access (短时记忆)&lt;/h3&gt;
&lt;p&gt;Tools可通过&lt;code&gt;runtime.state&lt;/code&gt;访问当前对话状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool, ToolRuntime
from langchain.messages import HumanMessage

@tool
def get_last_user_message(runtime: ToolRuntime) -&amp;gt; str:
    &quot;&quot;&quot;Get the most recent message from the user.&quot;&quot;&quot;
    messages = runtime.state[&quot;messages&quot;]

    # Find the last human message
    for message in reversed(messages):
        if isinstance(message, HumanMessage):
            return message.content

    return &quot;No user messages found&quot;

# Access custom state fields
@tool
def get_user_preference(
    pref_name: str,
    runtime: ToolRuntime
) -&amp;gt; str:
    &quot;&quot;&quot;Get a user preference value.&quot;&quot;&quot;
    preferences = runtime.state.get(&quot;user_preferences&quot;, {})
    return preferences.get(pref_name, &quot;Not set&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不仅如此，还可以用Command更新智能体的状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.types import Command
from langchain.tools import tool

@tool
def set_user_name(new_name: str) -&amp;gt; Command:
    &quot;&quot;&quot;Set the user&apos;s name in the conversation state.&quot;&quot;&quot;
    return Command(update={&quot;user_name&quot;: new_name})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) Context Access&lt;/h3&gt;
&lt;p&gt;上下文提供在调用时传递的不可变配置数据，可用于用户ID、会话详情或对话过程中不应更改的应用特定设置。通过&lt;code&gt;runtime.context&lt;/code&gt;访问上下文：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass
from langchain_openai import ChatOpenAI
from langchain.agents import create_agent
from langchain.tools import tool, ToolRuntime


USER_DATABASE = {
    &quot;user123&quot;: {
        &quot;name&quot;: &quot;Alice Johnson&quot;,
        &quot;account_type&quot;: &quot;Premium&quot;,
        &quot;balance&quot;: 5000,
        &quot;email&quot;: &quot;alice@example.com&quot;
    },
    &quot;user456&quot;: {
        &quot;name&quot;: &quot;Bob Smith&quot;,
        &quot;account_type&quot;: &quot;Standard&quot;,
        &quot;balance&quot;: 1200,
        &quot;email&quot;: &quot;bob@example.com&quot;
    }
}

@dataclass
class UserContext:
    user_id: str

@tool
def get_account_info(runtime: ToolRuntime[UserContext]) -&amp;gt; str:
    &quot;&quot;&quot;Get the current user&apos;s account information.&quot;&quot;&quot;
    user_id = runtime.context.user_id

    if user_id in USER_DATABASE:
        user = USER_DATABASE[user_id]
        return f&quot;Account holder: {user[&apos;name&apos;]}\nType: {user[&apos;account_type&apos;]}\nBalance: ${user[&apos;balance&apos;]}&quot;
    return &quot;User not found&quot;

model = ChatOpenAI(model=&quot;gpt-4.1&quot;)
agent = create_agent(
    model,
    tools=[get_account_info],
    context_schema=UserContext,
    system_prompt=&quot;You are a financial assistant.&quot;
)

result = agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What&apos;s my current balance?&quot;}]},
    context=UserContext(user_id=&quot;user123&quot;)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) Store Access (长时记忆)&lt;/h3&gt;
&lt;p&gt;BaseStore提供可跨对话持久保存的存储功能。与状态（短期记忆）不同，存储中保存的数据在后续会话中依然可用。&lt;/p&gt;
&lt;p&gt;通过runtime.store访问存储。存储采用命名空间或者key的模式来组织数据。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Any
from langgraph.store.memory import InMemoryStore
from langchain.agents import create_agent
from langchain.tools import tool, ToolRuntime
from langchain_openai import ChatOpenAI

# Access memory
@tool
def get_user_info(user_id: str, runtime: ToolRuntime) -&amp;gt; str:
    &quot;&quot;&quot;Look up user info.&quot;&quot;&quot;
    store = runtime.store
    user_info = store.get((&quot;users&quot;,), user_id)
    return str(user_info.value) if user_info else &quot;Unknown user&quot;

# Update memory
@tool
def save_user_info(user_id: str, user_info: dict[str, Any], runtime: ToolRuntime) -&amp;gt; str:
    &quot;&quot;&quot;Save user info.&quot;&quot;&quot;
    store = runtime.store
    store.put((&quot;users&quot;,), user_id, user_info)
    return &quot;Successfully saved user info.&quot;

model = ChatOpenAI(model=&quot;gpt-4.1&quot;)

store = InMemoryStore()
agent = create_agent(
    model,
    tools=[get_user_info, save_user_info],
    store=store
)

# First session: save user info
agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Save the following user: userid: abc123, name: Foo, age: 25, email: foo@langchain.dev&quot;}]
})

# Second session: get user info
agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Get user info for user with id &apos;abc123&apos;&quot;}]
})
# Here is the user info for user with ID &quot;abc123&quot;:
# - Name: Foo
# - Age: 25
# - Email: foo@langchain.dev
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(4) Stream Writer&lt;/h3&gt;
&lt;p&gt;在执行过程中流式传输来自工具的实时更新。这对于在长时间运行的操作期间向用户提供进度反馈非常有用。&lt;/p&gt;
&lt;p&gt;使用runtime.stream_writer来发送自定义更新：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool, ToolRuntime

@tool
def get_weather(city: str, runtime: ToolRuntime) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;
    writer = runtime.stream_writer

    # Stream custom updates as the tool executes
    writer(f&quot;Looking up data for city: {city}&quot;)
    writer(f&quot;Acquired data for city: {city}&quot;)

    return f&quot;It&apos;s always sunny in {city}!&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. ToolNode&lt;/h2&gt;
&lt;p&gt;ToolNode是一个预构建节点，用于在 LangGraph 工作流中执行工具。它会自动处理工具并行执行、错误处理和状态注入。这一块应该会在LangGraph中应用比较多。&lt;/p&gt;
&lt;p&gt;这个部分主要是用于精细控制工具执行模式的自定义工作流，不然可以直接使用create_agent。换句话说，它是支撑智能体工具执行的基础组件。&lt;/p&gt;
&lt;p&gt;由于这部分主要是LangGraph的内容，所以我将暂时跳过。&lt;/p&gt;
&lt;h2&gt;5. Tools的返回值&lt;/h2&gt;
&lt;p&gt;自定义工具@tool后，可以为工具选择不同的返回值：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;返回string，用于生成人类可读的结果。&lt;/li&gt;
&lt;li&gt;返回object，用于生成模型需要解析的结构化结果。&lt;/li&gt;
&lt;li&gt;返回Command（可附带消息），用于需要写入状态的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) String返回值&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool


@tool
def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a city.&quot;&quot;&quot;
    return f&quot;It is currently sunny in {city}.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;返回值会被转换为ToolMessage。&lt;/li&gt;
&lt;li&gt;模型会读取该文本并决定下一步操作。&lt;/li&gt;
&lt;li&gt;除非模型或其他工具后续修改，否则不会更改任何智能体状态字段。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果结果是人类可阅读的样子，应该选择此种返回。&lt;/p&gt;
&lt;h3&gt;(2) Object返回值&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool


@tool
def get_weather_data(city: str) -&amp;gt; dict:
    &quot;&quot;&quot;Get structured weather data for a city.&quot;&quot;&quot;
    return {
        &quot;city&quot;: city,
        &quot;temperature_c&quot;: 22,
        &quot;conditions&quot;: &quot;sunny&quot;,
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;该对象会被序列化后作为工具输出返回。&lt;/li&gt;
&lt;li&gt;模型可读取特定字段并基于这些字段进行推理。&lt;/li&gt;
&lt;li&gt;与字符串返回值类似，此操作不会直接更新图状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当下游推理可从显式字段而非自由格式文本中获益时，使用此方式。&lt;/p&gt;
&lt;h3&gt;(3) Command返回值&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.types import Command


@tool
def set_language(language: str, runtime: ToolRuntime) -&amp;gt; Command:
    &quot;&quot;&quot;Set the preferred response language.&quot;&quot;&quot;
    return Command(
        update={
            &quot;preferred_language&quot;: language,
            &quot;messages&quot;: [
                ToolMessage(
                    content=f&quot;Language set to {language}.&quot;,
                    tool_call_id=runtime.tool_call_id,
                )
            ],
        }
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;该命令通过update更新状态。&lt;/li&gt;
&lt;li&gt;更新后的状态可在同一次运行的后续步骤中使用。&lt;/li&gt;
&lt;li&gt;对于可能被并行工具调用更新的字段，请使用 reducer。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当工具不仅返回数据，还会修改智能体状态时使用此方法。&lt;/p&gt;
&lt;h2&gt;6. Prebuilt tools&lt;/h2&gt;
&lt;p&gt;LangChain 提供了大量适用于网络搜索、代码解析、数据库访问等常见任务的预制工具与工具包。这些开箱即用的工具可直接集成到你的Agent中，无需编写自定义代码。详见&lt;a href=&quot;https://docs.langchain.com/oss/python/integrations/tools&quot;&gt;这里&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;7. Server-side tool use&lt;/h2&gt;
&lt;p&gt;部分聊天模型具备由模型提供商在服务器端运行的内置工具。这些工具包括网络搜索、代码解释器等功能，你无需自行定义或托管工具逻辑。详见&lt;a href=&quot;https://docs.langchain.com/oss/python/integrations/providers/overview&quot;&gt;这里&lt;/a&gt;和这里&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/models#server-side-tool-use&quot;&gt;这里&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>Milvus 多模态实践：图文嵌入到检索闭环</title><link>https://owen571.top/posts/study/rag/06-milvus-%E5%A4%9A%E6%A8%A1%E6%80%81%E5%AE%9E%E8%B7%B5/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/06-milvus-%E5%A4%9A%E6%A8%A1%E6%80%81%E5%AE%9E%E8%B7%B5/</guid><description>用一个多模态例子把编码、建库、建索引、检索和可视化串成完整闭环，更接近真正落地的 RAG 实践。</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇不再停留在概念层，而是把前面的知识点真正串起来，做一条从图文编码到 Milvus 检索的完整链路。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - Milvus多模态实践&lt;/h1&gt;
&lt;h2&gt;1. 初始化与工具定义&lt;/h2&gt;
&lt;p&gt;首先导入所有必需的库，定义好模型路径、数据目录等常量。为了代码的整洁和复用，将 Visualized-BGE 模型的加载和编码逻辑封装在一个 Encoder 类中，并定义了一个 visualize_results 函数用于后续的结果可视化。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from tqdm import tqdm
from glob import glob
import torch
from visual_bge.visual_bge.modeling import Visualized_BGE
from pymilvus import MilvusClient, FieldSchema, CollectionSchema, DataType
import numpy as np
import cv2
from PIL import Image

# 1. 初始化设置
MODEL_NAME = &quot;BAAI/bge-base-en-v1.5&quot;
MODEL_PATH = &quot;../../models/bge/Visualized_base_en_v1.5.pth&quot;
DATA_DIR = &quot;../../data/C3&quot;
COLLECTION_NAME = &quot;multimodal_demo&quot;
MILVUS_URI = &quot;http://localhost:19530&quot;

# 2. 定义工具 (编码器和可视化函数)
class Encoder:
    &quot;&quot;&quot;编码器类，用于将图像和文本编码为向量。&quot;&quot;&quot;
    def __init__(self, model_name: str, model_path: str):
        self.model = Visualized_BGE(model_name_bge=model_name, model_weight=model_path)
        self.model.eval()

    def encode_query(self, image_path: str, text: str) -&amp;gt; list[float]:
        with torch.no_grad():
            query_emb = self.model.encode(image=image_path, text=text)
        return query_emb.tolist()[0]

    def encode_image(self, image_path: str) -&amp;gt; list[float]:
        with torch.no_grad():
            query_emb = self.model.encode(image=image_path)
        return query_emb.tolist()[0]

def visualize_results(query_image_path: str, retrieved_images: list, img_height: int = 300, img_width: int = 300, row_count: int = 3) -&amp;gt; np.ndarray:
    &quot;&quot;&quot;从检索到的图像列表创建一个全景图用于可视化。&quot;&quot;&quot;
    panoramic_width = img_width * row_count
    panoramic_height = img_height * row_count
    panoramic_image = np.full((panoramic_height, panoramic_width, 3), 255, dtype=np.uint8)
    query_display_area = np.full((panoramic_height, img_width, 3), 255, dtype=np.uint8)

    # 处理查询图像
    query_pil = Image.open(query_image_path).convert(&quot;RGB&quot;)
    query_cv = np.array(query_pil)[:, :, ::-1]
    resized_query = cv2.resize(query_cv, (img_width, img_height))
    bordered_query = cv2.copyMakeBorder(resized_query, 10, 10, 10, 10, cv2.BORDER_CONSTANT, value=(255, 0, 0))
    query_display_area[img_height * (row_count - 1):, :] = cv2.resize(bordered_query, (img_width, img_height))
    cv2.putText(query_display_area, &quot;Query&quot;, (10, panoramic_height - 20), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)

    # 处理检索到的图像
    for i, img_path in enumerate(retrieved_images):
        row, col = i // row_count, i % row_count
        start_row, start_col = row * img_height, col * img_width
        
        retrieved_pil = Image.open(img_path).convert(&quot;RGB&quot;)
        retrieved_cv = np.array(retrieved_pil)[:, :, ::-1]
        resized_retrieved = cv2.resize(retrieved_cv, (img_width - 4, img_height - 4))
        bordered_retrieved = cv2.copyMakeBorder(resized_retrieved, 2, 2, 2, 2, cv2.BORDER_CONSTANT, value=(0, 0, 0))
        panoramic_image[start_row:start_row + img_height, start_col:start_col + img_width] = bordered_retrieved
        
        # 添加索引号
        cv2.putText(panoramic_image, str(i), (start_col + 10, start_row + 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)

    return np.hstack([query_display_area, panoramic_image])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初看代码两眼一黑，现在我们来拆解。&lt;/p&gt;
&lt;p&gt;开头导包的环节，有几个还不太熟悉的，简单看看。tqdm是一个快速可拓展的python进度条，glob是用来查看符合特定规则的目录和文件将搜索到的结果返回到一个列表。&lt;/p&gt;
&lt;p&gt;导入模型的环节，作者有一个带setup.py的路径all-in-rag/code/C3/visual_bge，下面有一个visual_bge文件夹，里面还有modeling.py，作者就是从这里导入了这个Visualized_BGE类。这是作者对将几部分模型能力拼出来的，比如文本部分用Hugging Face 的 AutoConfig / AutoModel 加载 BGE 底座；视觉部分用 create_eva_vision_and_transforms(...) 引入 EVA-CLIP 视觉编码器；对齐层作者自己加了一个 visual_proj = nn.Linear(...)，把视觉特征映射到和 BGE 一致的语义空间；权重加载通过 self.load_state_dict(torch.load(...)) 把训练好的 Visualized-BGE 权重灌进去。&lt;/p&gt;
&lt;p&gt;然后就是pymilvus提供的几个包，和处理图像用的几个包。&lt;/p&gt;
&lt;p&gt;常量配置部分，做了一些全局配置，包括模型名、目录、Collection名、Milvus地址。&lt;/p&gt;
&lt;p&gt;Encoder类封装了关键的encode_image()和encode_query()方法，用于创建模型对象进行推理，将输出的二位张量取出需要的向量，包含纯图片和图+文，从而得到嵌入向量。&lt;/p&gt;
&lt;p&gt;visualize_results则是可视化结果。&lt;/p&gt;
&lt;h2&gt;2. 创建Colletion&lt;/h2&gt;
&lt;p&gt;这是与 Milvus 交互的开始。首先初始化 Milvus 客户端，然后定义 Collection 的 Schema，它规定了集合的数据结构。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 3. 初始化客户端
print(&quot;--&amp;gt; 正在初始化编码器和Milvus客户端...&quot;)
encoder = Encoder(MODEL_NAME, MODEL_PATH)
milvus_client = MilvusClient(uri=MILVUS_URI)

# 4. 创建 Milvus Collection
print(f&quot;\n--&amp;gt; 正在创建 Collection &apos;{COLLECTION_NAME}&apos;&quot;)
if milvus_client.has_collection(COLLECTION_NAME):
    milvus_client.drop_collection(COLLECTION_NAME)
    print(f&quot;已删除已存在的 Collection: &apos;{COLLECTION_NAME}&apos;&quot;)

image_list = glob(os.path.join(DATA_DIR, &quot;dragon&quot;, &quot;*.png&quot;))
if not image_list:
    raise FileNotFoundError(f&quot;在 {DATA_DIR}/dragon/ 中未找到任何 .png 图像。&quot;)
dim = len(encoder.encode_image(image_list[0]))

fields = [
    FieldSchema(name=&quot;id&quot;, dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name=&quot;vector&quot;, dtype=DataType.FLOAT_VECTOR, dim=dim),
    FieldSchema(name=&quot;image_path&quot;, dtype=DataType.VARCHAR, max_length=512),
]

# 创建集合 Schema
schema = CollectionSchema(fields, description=&quot;多模态图文检索&quot;)
print(&quot;Schema 结构:&quot;)
print(schema)

# 创建集合
milvus_client.create_collection(collection_name=COLLECTION_NAME, schema=schema)
print(f&quot;成功创建 Collection: &apos;{COLLECTION_NAME}&apos;&quot;)
print(&quot;Collection 结构:&quot;)
print(milvus_client.describe_collection(collection_name=COLLECTION_NAME))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的代码比较直白，但是涉及到和Milvus的交互，注意几个方法。首先用&lt;code&gt;MilvusClient&lt;/code&gt;类创建实例，然后做了一个预先处理：如果有同名Collection，先drop掉（这是为了每次从干净状态开始，否则会有旧数据残留的可能，正式生产环境一般不会这么做，这么做事为了反复运行demo）。&lt;/p&gt;
&lt;p&gt;紧接着，用glob来提取png成列表，通过 &lt;code&gt;encoder.encode_image(image_list[0])&lt;/code&gt; 对第一张图片进行编码，并用 &lt;code&gt;len(...)&lt;/code&gt; 获取向量维度。这是因为 Milvus 在创建 &lt;code&gt;FLOAT_VECTOR&lt;/code&gt; 字段时，必须提前知道向量维度。&lt;/p&gt;
&lt;p&gt;接着，我们去定义一下需要用的元素的类型作为schema，传入&lt;code&gt;create_collection&lt;/code&gt;方法构建collection。本次定义了三个字段，作为主键的id，INT64；图像对应的向量类型，类型为FLOAT_VECTOR；然后是图片路径，类型VERCHAR。&lt;/p&gt;
&lt;p&gt;然后，我们传入Schema去构建了Collection。&lt;/p&gt;
&lt;p&gt;输出Collections和Schema的结构类似于（此处是作者示例）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--&amp;gt; 正在创建 Collection &apos;multimodal_demo&apos;

Schema 结构:
{
    &apos;auto_id&apos;: True, 
    &apos;description&apos;: &apos;多模态图文检索&apos;, 
    &apos;fields&apos;: [
        {&apos;name&apos;: &apos;id&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.INT64: 5&amp;gt;, &apos;is_primary&apos;: True, &apos;auto_id&apos;: True}, 
        {&apos;name&apos;: &apos;vector&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.FLOAT_VECTOR: 101&amp;gt;, &apos;params&apos;: {&apos;dim&apos;: 768}}, 
        {&apos;name&apos;: &apos;image_path&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.VARCHAR: 21&amp;gt;, &apos;params&apos;: {&apos;max_length&apos;: 512}}
    ], 
    &apos;enable_dynamic_field&apos;: False
}

成功创建 Collection: &apos;multimodal_demo&apos;

Collection 结构:
{
    &apos;collection_name&apos;: &apos;multimodal_demo&apos;, 
    &apos;auto_id&apos;: True, 
    &apos;num_shards&apos;: 1, 
    &apos;description&apos;: &apos;多模态图文检索&apos;, 
    &apos;fields&apos;: [
        {&apos;field_id&apos;: 100, &apos;name&apos;: &apos;id&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.INT64: 5&amp;gt;, &apos;params&apos;: {}, &apos;auto_id&apos;: True, &apos;is_primary&apos;: True}, 
        {&apos;field_id&apos;: 101, &apos;name&apos;: &apos;vector&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.FLOAT_VECTOR: 101&amp;gt;, &apos;params&apos;: {&apos;dim&apos;: 768}}, 
        {&apos;field_id&apos;: 102, &apos;name&apos;: &apos;image_path&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.VARCHAR: 21&amp;gt;, &apos;params&apos;: {&apos;max_length&apos;: 512}}
    ], 
    &apos;functions&apos;: [], 
    &apos;aliases&apos;: [], 
    &apos;collection_id&apos;: 459243798405253751, 
    &apos;consistency_level&apos;: 2, 
    &apos;properties&apos;: {}, 
    &apos;num_partitions&apos;: 1, 
    &apos;enable_dynamic_field&apos;: False, 
    &apos;created_timestamp&apos;: 459249546649403396, 
    &apos;update_timestamp&apos;: 459249546649403396
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 准备并插入数据&lt;/h2&gt;
&lt;p&gt;创建好 Collection 后，需要将数据填充进去。通过遍历指定目录下的所有图片，将它们逐一编码成向量，然后与图片路径一起组织成符合 Schema 结构的格式，最后批量插入到 Collection 中。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 5. 准备并插入数据
print(f&quot;\n--&amp;gt; 正在向 &apos;{COLLECTION_NAME}&apos; 插入数据&quot;)
data_to_insert = []
for image_path in tqdm(image_list, desc=&quot;生成图像嵌入&quot;):
    vector = encoder.encode_image(image_path)
    data_to_insert.append({&quot;vector&quot;: vector, &quot;image_path&quot;: image_path})

if data_to_insert:
    result = milvus_client.insert(collection_name=COLLECTION_NAME, data=data_to_insert)
    print(f&quot;成功插入 {result[&apos;insert_count&apos;]} 条数据。&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 创建索引&lt;/h2&gt;
&lt;p&gt;为了实现快速检索，需要为向量字段创建索引。这里选择 HNSW 索引，它在召回率和查询性能之间有着很好的平衡。创建索引后，必须调用 load_collection 将集合加载到内存中才能进行搜索。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 6. 创建索引
print(f&quot;\n--&amp;gt; 正在为 &apos;{COLLECTION_NAME}&apos; 创建索引&quot;)
index_params = milvus_client.prepare_index_params()
index_params.add_index(
    field_name=&quot;vector&quot;,
    index_type=&quot;HNSW&quot;,
    metric_type=&quot;COSINE&quot;,
    params={&quot;M&quot;: 16, &quot;efConstruction&quot;: 256}
)
milvus_client.create_index(collection_name=COLLECTION_NAME, index_params=index_params)
print(&quot;成功为向量字段创建 HNSW 索引。&quot;)
print(&quot;索引详情:&quot;)
print(milvus_client.describe_index(collection_name=COLLECTION_NAME, index_name=&quot;vector&quot;))
milvus_client.load_collection(collection_name=COLLECTION_NAME)
print(&quot;已加载 Collection 到内存中。&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 执行多模态检索&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 7. 执行多模态检索
print(f&quot;\n--&amp;gt; 正在 &apos;{COLLECTION_NAME}&apos; 中执行检索&quot;)
query_image_path = os.path.join(DATA_DIR, &quot;dragon&quot;, &quot;query.png&quot;)
query_text = &quot;一条龙&quot;
query_vector = encoder.encode_query(image_path=query_image_path, text=query_text)

search_results = milvus_client.search(
    collection_name=COLLECTION_NAME,
    data=[query_vector],
    output_fields=[&quot;image_path&quot;],
    limit=5,
    search_params={&quot;metric_type&quot;: &quot;COSINE&quot;, &quot;params&quot;: {&quot;ef&quot;: 128}}
)[0]

retrieved_images = []
print(&quot;检索结果:&quot;)
for i, hit in enumerate(search_results):
    print(f&quot;  Top {i+1}: ID={hit[&apos;id&apos;]}, 距离={hit[&apos;distance&apos;]:.4f}, 路径=&apos;{hit[&apos;entity&apos;][&apos;image_path&apos;]}&apos;&quot;)
    retrieved_images.append(hit[&apos;entity&apos;][&apos;image_path&apos;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出结果会类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--&amp;gt; 正在 &apos;multimodal_demo&apos; 中执行检索
检索结果:
  Top 1: ID=459243798403756667, 距离=0.9411, 路径=&apos;../../data/C3\dragon\dragon01.png&apos;
  Top 2: ID=459243798403756668, 距离=0.5818, 路径=&apos;../../data/C3\dragon\dragon02.png&apos;
  Top 3: ID=459243798403756671, 距离=0.5731, 路径=&apos;../../data/C3\dragon\dragon05.png&apos;
  Top 4: ID=459243798403756670, 距离=0.4894, 路径=&apos;../../data/C3\dragon\dragon04.png&apos;
  Top 5: ID=459243798403756669, 距离=0.4100, 路径=&apos;../../data/C3\dragon\dragon03.png&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 可视化与清理&lt;/h2&gt;
&lt;p&gt;最后，将检索到的图片路径用于可视化，生成一张直观的结果对比图。在完成所有操作后，应该释放 Milvus 中的资源，包括从内存中卸载 Collection 和删除整个 Collection。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 8. 可视化与清理
print(f&quot;\n--&amp;gt; 正在可视化结果并清理资源&quot;)
if not retrieved_images:
    print(&quot;没有检索到任何图像。&quot;)
else:
    panoramic_image = visualize_results(query_image_path, retrieved_images)
    combined_image_path = os.path.join(DATA_DIR, &quot;search_result.png&quot;)
    cv2.imwrite(combined_image_path, panoramic_image)
    print(f&quot;结果图像已保存到: {combined_image_path}&quot;)
    Image.open(combined_image_path).show()

milvus_client.release_collection(collection_name=COLLECTION_NAME)
print(f&quot;已从内存中释放 Collection: &apos;{COLLECTION_NAME}&apos;&quot;)
milvus_client.drop_collection(COLLECTION_NAME)
print(f&quot;已删除 Collection: &apos;{COLLECTION_NAME}&apos;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;7. 结果&lt;/h2&gt;
&lt;p&gt;过程日志如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(all-in-rag) ➜  C3 git:(main) ✗ python 04_multi_milvus.py
/opt/homebrew/anaconda3/envs/all-in-rag/lib/python3.12/site-packages/timm/models/layers/__init__.py:49: FutureWarning: Importing from timm.models.layers is deprecated, please import via timm.layers
  warnings.warn(f&quot;Importing from {__name__} is deprecated, please import via timm.layers&quot;, FutureWarning)
--&amp;gt; 正在初始化编码器和Milvus客户端...
tokenizer_config.json: 100%|█| 366/366 [00:00&amp;lt;00:00, 624kB/s
vocab.txt: 232kB [00:00, 826kB/s] 
special_tokens_map.json: 100%|█| 125/125 [00:00&amp;lt;00:00, 163kB
tokenizer.json: 711kB [00:00, 2.91MB/s]

--&amp;gt; 正在创建 Collection &apos;multimodal_demo&apos;
Schema 结构:
{&apos;auto_id&apos;: True, &apos;description&apos;: &apos;多模态图文检索&apos;, &apos;fields&apos;: [{&apos;name&apos;: &apos;id&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.INT64: 5&amp;gt;, &apos;is_primary&apos;: True, &apos;auto_id&apos;: True}, {&apos;name&apos;: &apos;vector&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.FLOAT_VECTOR: 101&amp;gt;, &apos;params&apos;: {&apos;dim&apos;: 768}}, {&apos;name&apos;: &apos;image_path&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.VARCHAR: 21&amp;gt;, &apos;params&apos;: {&apos;max_length&apos;: 512}}], &apos;enable_dynamic_field&apos;: False}
成功创建 Collection: &apos;multimodal_demo&apos;
Collection 结构:
{&apos;collection_name&apos;: &apos;multimodal_demo&apos;, &apos;auto_id&apos;: True, &apos;num_shards&apos;: 1, &apos;description&apos;: &apos;多模态图文检索&apos;, &apos;fields&apos;: [{&apos;field_id&apos;: 100, &apos;name&apos;: &apos;id&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.INT64: 5&amp;gt;, &apos;params&apos;: {}, &apos;auto_id&apos;: True, &apos;is_primary&apos;: True}, {&apos;field_id&apos;: 101, &apos;name&apos;: &apos;vector&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.FLOAT_VECTOR: 101&amp;gt;, &apos;params&apos;: {&apos;dim&apos;: 768}}, {&apos;field_id&apos;: 102, &apos;name&apos;: &apos;image_path&apos;, &apos;description&apos;: &apos;&apos;, &apos;type&apos;: &amp;lt;DataType.VARCHAR: 21&amp;gt;, &apos;params&apos;: {&apos;max_length&apos;: 512}}], &apos;functions&apos;: [], &apos;aliases&apos;: [], &apos;collection_id&apos;: 465268713610543383, &apos;consistency_level&apos;: 2, &apos;properties&apos;: {}, &apos;num_partitions&apos;: 1, &apos;enable_dynamic_field&apos;: False, &apos;created_timestamp&apos;: 465268727841554436, &apos;update_timestamp&apos;: 465268727841554436}

--&amp;gt; 正在向 &apos;multimodal_demo&apos; 插入数据
生成图像嵌入: 100%|███████████| 7/7 [00:02&amp;lt;00:00,  2.42it/s]
成功插入 7 条数据。

--&amp;gt; 正在为 &apos;multimodal_demo&apos; 创建索引
成功为向量字段创建 HNSW 索引。
索引详情:
{&apos;M&apos;: &apos;16&apos;, &apos;efConstruction&apos;: &apos;256&apos;, &apos;metric_type&apos;: &apos;COSINE&apos;, &apos;index_type&apos;: &apos;HNSW&apos;, &apos;field_name&apos;: &apos;vector&apos;, &apos;index_name&apos;: &apos;vector&apos;, &apos;total_rows&apos;: 0, &apos;indexed_rows&apos;: 0, &apos;pending_index_rows&apos;: 0, &apos;state&apos;: &apos;Finished&apos;}
已加载 Collection 到内存中。

--&amp;gt; 正在 &apos;multimodal_demo&apos; 中执行检索
检索结果:
  Top 1: ID=465268713610543405, 距离=0.9466, 路径=&apos;/Users/owen/AI_learning/RAG/all-in-rag/data/C3/dragon/query.png&apos;
  Top 2: ID=465268713610543410, 距离=0.7443, 路径=&apos;/Users/owen/AI_learning/RAG/all-in-rag/data/C3/dragon/dragon02.png&apos;
  Top 3: ID=465268713610543407, 距离=0.6851, 路径=&apos;/Users/owen/AI_learning/RAG/all-in-rag/data/C3/dragon/dragon06.png&apos;
  Top 4: ID=465268713610543408, 距离=0.6049, 路径=&apos;/Users/owen/AI_learning/RAG/all-in-rag/data/C3/dragon/dragon03.png&apos;
  Top 5: ID=465268713610543404, 距离=0.5360, 路径=&apos;/Users/owen/AI_learning/RAG/all-in-rag/data/C3/dragon/dragon05.png&apos;

--&amp;gt; 正在可视化结果并清理资源
结果图像已保存到: /Users/owen/AI_learning/RAG/all-in-rag/data/C3/search_result.png
已从内存中释放 Collection: &apos;multimodal_demo&apos;
已删除 Collection: &apos;multimodal_demo&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-17.kcQ-APk2.png&amp;amp;w=2360&amp;amp;h=1872&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>可验证强化学习：RLVR 与 Tülu 3</title><link>https://owen571.top/posts/study/reinforce-learning/08-rlvr-%E4%B8%8E%E5%8F%AF%E9%AA%8C%E8%AF%81%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/08-rlvr-%E4%B8%8E%E5%8F%AF%E9%AA%8C%E8%AF%81%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0/</guid><description>从 Tülu 3 出发理解 RLVR，看看当奖励可以被规则直接验证时，强化学习会如何变化。</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;在LLMs的快速发展中, 强化学习与可验证奖励作为一种创新的训练方法, 引起了广泛关注. RLVR通过使用可验证的、基于规则的奖励函数, 为模型提供明确的二元反馈, 从而优化其性能. 与传统的RLHF不同, RLVR避免了主观人类评估或复杂奖励模型的依赖, 使得训练过程更加透明高效. 并且这种方式特别适用于数学推理、代码生成等具有明确正确性标准的任务.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. 起源论文精读&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;可验证奖励的强化学习(Reinforcement Learning with Verifiable Rewards, RLVR)&lt;/strong&gt;, 首次在Tülu3项目中提出, 论文&lt;a href=&quot;http://arxiv.org/abs/2411.15124&quot;&gt;Tülu 3: Pushing Frontiers in Open Language Model Post-Training&lt;/a&gt;, 现在我们来具体阅读和实操一下论文所说的东西.&lt;/p&gt;
&lt;p&gt;本论文未来弥补开源和闭源post training之间的差距, 本文提出了Tülu3 -- 一系列开放的、最先进的后训练模型, 包括他们的全部相关数据、训练配方、代码、基础设设施和评估框架.&lt;/p&gt;
&lt;p&gt;先来看使用了RLVF方法等Tülu3模型, 在三个尺度下(405B、70B、8B) 的性能比较, 涵盖了综合能力（Avg）、知识（MMLU）、数学（MATH, GSM8K）、代码（HumanEval）、指令遵循（IFEval）和安全性（Safety）等多个维度.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251122145456.BZxuNKib.png&amp;amp;w=1752&amp;amp;h=608&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251122145606.CaM-WboC.png&amp;amp;w=1654&amp;amp;h=756&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251122145634.Bx2MytM3.png&amp;amp;w=1742&amp;amp;h=720&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;从上述表格可以看出:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;超大模型对比方面, GPT-4o依然遥遥领先, 但Tülu3的RLVR表现强劲, 优于Llama3.1 405B Instruct而均分接近GPT-4o. 另外Deepseek V3在数学题和BigBenchHard上表现突出&lt;/li&gt;
&lt;li&gt;大模型对比方面, Tülu3 70B综合第一, 而Qwen2.5 72B在MATH和HumanEval+上具有压倒性优势.&lt;/li&gt;
&lt;li&gt;小模型方面, Qwen2.5 7B统治力最强, 大幅领先对手. 但是Tülu3 8B则在小学数学(GSM8K)、IFEval(指令微调)和Safety(安全性)上表现很好.&lt;/li&gt;
&lt;li&gt;Tülu3 的后训练策略, 从SFT到DPO再到RLVR, 性能都有显著且稳定的提升.&lt;/li&gt;
&lt;li&gt;RLVR实际上起到了修复逻辑功能的作用, 比如在SFT到DPO, MATH任务反而准确度降低了, 而采用RLVR之后产生了大幅反超. 另外在指令准确度和特定领域(小学数学)的上限也发生了提高 (我们可以认为, RLVR对“刷分”行为进行了一定程度的制裁, 在风格偏好调整好的情况下, 又把丢失的部分逻辑功能拿了回来.)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Tülu 3团队自己总结该架构的重要元素如下:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;仔细调研了开源数据集, 分析其来源并进行了去噪, 同时进行策划了针对核心技能的合成提示, 以获取高质量的提示. 并发现了&lt;strong&gt;针对性的提示对提升核心技能具有显著的影响&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;创造了一个多经验的SFT数据集, 通过构建专门针对的模型在评估套件中确定一个上限, 然后通过混合数据使通用模型去接近这一上限.&lt;/li&gt;
&lt;li&gt;构建了一种同策略偏好数据集, 通过一种同策略数据整理流程, 来拓展偏好数据集生产规模. 具体而言是用Tülu3-SFT及其他模型生成补全结果, 并通过两两比较获得偏好标签. 最终获得了354,192个用于偏好调优的数据实例.&lt;/li&gt;
&lt;li&gt;在偏好微调算法设计中, 实验中优先考虑了简单性和效率, 因此整个开发过程中采用了长度归一化的DPO, 而没有投入更多成本去研究基于强化学习的方法如PPO.&lt;/li&gt;
&lt;li&gt;采用了具有可验证奖励的特定技能强化学习, 利用标准的强化学习范式来针对那些能够与真实结果进行评估的技能 (例如数学), 这一方法被称为RLVR. 当任务完成时, 该算法会获得一个恒定的奖励值, 这显著提升了GSM8K、MATH和IFEval的表现.&lt;/li&gt;
&lt;li&gt;实现了一种异步训练架构, 通过vLLM高效运行大语言模型推理, 同时学习器并行执行梯度更新.&lt;/li&gt;
&lt;li&gt;实现了评估框架Tülu 3 Eval, 是一款开放的评估工具包, 旨在通过精心挑选的评估套件和去噪工具, 指导开发过程.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;1. Tülu 3 数据集&lt;/h2&gt;
&lt;p&gt;Tülu数据集是通过整合公开数据并人工精选数据, 来策划和收集得到的. 它聚焦于知识回忆、推理能力、数学、编程、指令执行、通用对话和安全等核心技能.&lt;/p&gt;
&lt;h3&gt;(1) 提示词精选&lt;/h3&gt;
&lt;p&gt;团队首先对公开数据集进行了广泛的调研, 然后对每个数据集进行人工审核, 并根据以下考量挑选出合适的数据集:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;多样性&lt;/li&gt;
&lt;li&gt;目标技能&lt;/li&gt;
&lt;li&gt;数据溯源与许可&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251122221155.XpthxSOP.png&amp;amp;w=1762&amp;amp;h=1838&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. Tülu 3 评估工具&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;评估工具在 https://github.com/allenai/olmes 上公开.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;3. Tülu 3 配方&lt;/h2&gt;
&lt;p&gt;现在, 来介绍一下Tülu3这个模型时怎么训练出来的, 据团队自己所说, Tülu3的关键贡献在于数据、方法、架构改变和严格评估.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251122154811.B92RF1W_.png&amp;amp;w=1762&amp;amp;h=630&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;(1) 数据整理&lt;/h3&gt;
&lt;h3&gt;(2) 监督微调&lt;/h3&gt;
&lt;h3&gt;(3) 偏好微调&lt;/h3&gt;
&lt;h3&gt;(4) RLVR&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251123140317.oGT5P62h.png&amp;amp;w=752&amp;amp;h=412&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>LangChain 核心组件 04：Short-term Memory</title><link>https://owen571.top/posts/study/langchain/06-short-term-memory/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/06-short-term-memory/</guid><description>把对话线程里的状态和历史真正留住，并学会在上下文有限时修剪、删除、总结消息。</description><pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇我放在 Tools 后面，是因为记忆本质上是在“模型 + 消息 + 工具调用”都成立之后，才真正开始变得重要。它处理的是对话变长之后的现实问题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;记忆是一种能够记录过往交互信息的系统。对于智能体而言，记忆至关重要，因为它能让智能体记住过往的交互过程，从反馈中学习，并适应用户的偏好。当智能体处理涉及大量用户交互的复杂任务时，这项能力对于提升效率与用户满意度都不可或缺。&lt;/p&gt;
&lt;p&gt;短期记忆可让应用程序在单一对话线程或对话中记住过往的交互内容。&lt;/p&gt;
&lt;p&gt;对话历史是短期记忆最常见的形式。冗长的对话对当下的大语言模型构成挑战；完整的对话历史可能无法容纳于大语言模型的上下文窗口中，进而导致上下文丢失或错误。&lt;/p&gt;
&lt;p&gt;即便你的模型支持完整的上下文长度，大多数大语言模型在处理长上下文时的表现依然不佳。它们会被过时或无关的内容“干扰”，同时还会面临响应速度变慢、成本升高的问题。&lt;/p&gt;
&lt;p&gt;聊天模型通过Message接收上下文信息，这些消息包含指令（系统消息）和输入内容（用户消息）。在聊天应用中，消息会在用户输入与模型回复之间交替呈现，由此形成的消息列表会随着时间推移不断变长。由于上下文窗口存在限制，许多应用都可以借助相关技术来移除或“遗忘”过时信息。&lt;/p&gt;
&lt;h2&gt;2. 基本使用&lt;/h2&gt;
&lt;p&gt;要为智能体添加短期记忆（线程级持久化），你需要在创建智能体时指定一个checkpointer。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver  


agent = create_agent(
    &quot;gpt-5&quot;,
    tools=[get_user_info],
    checkpointer=InMemorySaver(),
)

agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Hi! My name is Bob.&quot;}]},
    {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}},
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果要使用记忆，我们必须要定义好一个线程。如果，我们要生成随机线程号，可以用&lt;code&gt;{&quot;configurable&quot;: {&quot;thread_id&quot;: str(uuid.uuid4())}}&lt;/code&gt;，uuid.uuid4()是生成一个新的随机的UUID再转成字符串。&lt;/p&gt;
&lt;p&gt;在持久化场景中，configurable最常用的就是thread_id，此外也是有其他键的，也可以自己定义业务参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config = {
    &quot;configurable&quot;: {
        &quot;thread_id&quot;: &quot;thread-1&quot;,
        &quot;user_id&quot;: &quot;owen&quot;,
        &quot;lang&quot;: &quot;zh&quot;,
        &quot;tenant_id&quot;: &quot;school-a&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再往深盘一下，config的完整结果如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config = {
    &quot;configurable&quot;: {
        # 运行逻辑要用的参数
        &quot;thread_id&quot;: &quot;...&quot;,
        &quot;user_id&quot;: &quot;...&quot;,
        &quot;lang&quot;: &quot;zh&quot;,
    },
    # 观测/追踪用
    &quot;tags&quot;: [...],
    &quot;metadata&quot;: {...},
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而在生产环境中，往往使用数据库支持的检查点保存器，如使用langgraph提供的和Postgres结合的包：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install langgraph-checkpoint-postgres
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后，我们用如下语法连接数据库：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent

from langgraph.checkpoint.postgres import PostgresSaver  


DB_URI = &quot;postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable&quot;
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup() # auto create tables in PostgreSQL
    agent = create_agent(
        &quot;gpt-5&quot;,
        tools=[get_user_info],
        checkpointer=checkpointer,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;至于对更多数据库的支持，看&lt;a href=&quot;https://docs.langchain.com/oss/python/langgraph/persistence#checkpointer-libraries&quot;&gt;这里&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;3. 自定义agent记忆&lt;/h2&gt;
&lt;p&gt;默认情况下，agents通过AgentState来管理短期记忆，比如直接用message键来查看对话历史。&lt;/p&gt;
&lt;p&gt;但是，我们也可以给AgentState加入别的信息，自定义的state schemas会被传递给create_agent的state_schema参数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent, AgentState
from langgraph.checkpoint.memory import InMemorySaver


class CustomAgentState(AgentState):
    user_id: str
    preferences: dict

agent = create_agent(
    &quot;gpt-5&quot;,
    tools=[get_user_info],
    state_schema=CustomAgentState,
    checkpointer=InMemorySaver(),
)

# Custom state can be passed in invoke
result = agent.invoke(
    {
        &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Hello&quot;}],
        &quot;user_id&quot;: &quot;user_123&quot;,
        &quot;preferences&quot;: {&quot;theme&quot;: &quot;dark&quot;}
    },
    {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}})
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 超出上下文的解决方案&lt;/h2&gt;
&lt;h3&gt;(1) Trim messages&lt;/h3&gt;
&lt;p&gt;大多数大语言模型都有其支持的最大上下文窗口（以令牌为单位计量）。&lt;/p&gt;
&lt;p&gt;判断何时截断消息的一种方法是统计消息历史中的令牌数量，当令牌数接近该上限时便进行截断。若你使用 LangChain 框架，可借助消息裁剪工具，指定需要保留的令牌数量，以及处理边界时所采用的strategy（例如保留最后max_tokens个令牌）。&lt;/p&gt;
&lt;p&gt;若要在Agent中裁剪消息历史，可使用@before_model中间件装饰器，示例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.checkpoint.memory import InMemorySaver
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import before_model
from langgraph.runtime import Runtime
from langchain_core.runnables import RunnableConfig
from typing import Any


@before_model
def trim_messages(state: AgentState, runtime: Runtime) -&amp;gt; dict[str, Any] | None:
    &quot;&quot;&quot;Keep only the last few messages to fit context window.&quot;&quot;&quot;
    messages = state[&quot;messages&quot;]

    if len(messages) &amp;lt;= 3:
        return None  # No changes needed

    first_msg = messages[0]
    recent_messages = messages[-3:] if len(messages) % 2 == 0 else messages[-4:]
    new_messages = [first_msg] + recent_messages

    return {
        &quot;messages&quot;: [
            RemoveMessage(id=REMOVE_ALL_MESSAGES),
            *new_messages
        ]
    }

agent = create_agent(
    your_model_here,
    tools=your_tools_here,
    middleware=[trim_messages],
    checkpointer=InMemorySaver(),
)

config: RunnableConfig = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}

agent.invoke({&quot;messages&quot;: &quot;hi, my name is bob&quot;}, config)
agent.invoke({&quot;messages&quot;: &quot;write a short poem about cats&quot;}, config)
agent.invoke({&quot;messages&quot;: &quot;now do the same but for dogs&quot;}, config)
final_response = agent.invoke({&quot;messages&quot;: &quot;what&apos;s my name?&quot;}, config)

final_response[&quot;messages&quot;][-1].pretty_print()
&quot;&quot;&quot;
================================== Ai Message ==================================

Your name is Bob. You told me that earlier.
If you&apos;d like me to call you a nickname or use a different name, just say the word.
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以看到，trim_message方法被加上&lt;code&gt;@before_model&lt;/code&gt;装饰器，放进了中间件（之前介绍过before_model的位置。这里使用了langgraph.graph.message的方法，REMOVE_ALL_MESSAGES。还是使用了一些高级用法，比如运行时，这里暂时不用看。&lt;/p&gt;
&lt;h3&gt;(2) Delete message&lt;/h3&gt;
&lt;p&gt;这里使用RemoveMessage把消息从图中删掉&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import RemoveMessage  

def delete_messages(state):
    messages = state[&quot;messages&quot;]
    if len(messages) &amp;gt; 2:
        # remove the earliest two messages
        return {&quot;messages&quot;: [RemoveMessage(id=m.id) for m in messages[:2]]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果是要删除所有消息，就按照trim message方案中那样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.graph.message import REMOVE_ALL_MESSAGES  

def delete_messages(state):
    return {&quot;messages&quot;: [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;给出一个完整删除最早期两个消息的过程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import RemoveMessage
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import after_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.runtime import Runtime
from langchain_core.runnables import RunnableConfig


@after_model
def delete_old_messages(state: AgentState, runtime: Runtime) -&amp;gt; dict | None:
    &quot;&quot;&quot;Remove old messages to keep conversation manageable.&quot;&quot;&quot;
    messages = state[&quot;messages&quot;]
    if len(messages) &amp;gt; 2:
        # remove the earliest two messages
        return {&quot;messages&quot;: [RemoveMessage(id=m.id) for m in messages[:2]]}
    return None


agent = create_agent(
    &quot;gpt-5-nano&quot;,
    tools=[],
    system_prompt=&quot;Please be concise and to the point.&quot;,
    middleware=[delete_old_messages],
    checkpointer=InMemorySaver(),
)

config: RunnableConfig = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}

for event in agent.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;hi! I&apos;m bob&quot;}]},
    config,
    stream_mode=&quot;values&quot;,
):
    print([(message.type, message.content) for message in event[&quot;messages&quot;]])

for event in agent.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;what&apos;s my name?&quot;}]},
    config,
    stream_mode=&quot;values&quot;,
):
    print([(message.type, message.content) for message in event[&quot;messages&quot;]])
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) Summarize messages&lt;/h3&gt;
&lt;p&gt;如上所示，裁剪或删除消息的问题在于，消息队列的筛选操作可能会导致信息丢失。正因如此，部分应用采用更为复杂的方法，即借助对话模型对消息历史进行总结，从而获得更好的效果。
&lt;img src=&quot;https://owen571.top/images/study/langchain/image-10.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;我们使用SummarizationMiddleware中间件对历史对话进行总结，完整用法示例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.runnables import RunnableConfig


checkpointer = InMemorySaver()

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[],
    middleware=[
        SummarizationMiddleware(
            model=&quot;gpt-4.1-mini&quot;,
            trigger=(&quot;tokens&quot;, 4000),
            keep=(&quot;messages&quot;, 20)
        )
    ],
    checkpointer=checkpointer,
)

config: RunnableConfig = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}
agent.invoke({&quot;messages&quot;: &quot;hi, my name is bob&quot;}, config)
agent.invoke({&quot;messages&quot;: &quot;write a short poem about cats&quot;}, config)
agent.invoke({&quot;messages&quot;: &quot;now do the same but for dogs&quot;}, config)
final_response = agent.invoke({&quot;messages&quot;: &quot;what&apos;s my name?&quot;}, config)

final_response[&quot;messages&quot;][-1].pretty_print()
&quot;&quot;&quot;
================================== Ai Message ==================================

Your name is Bob!
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 访问记忆&lt;/h2&gt;
&lt;p&gt;可以通过多种方式访问和修改智能体的短期记忆（也叫state）。&lt;/p&gt;
&lt;h3&gt;(1) 工具&lt;/h3&gt;
&lt;p&gt;在工具一节就详细介绍过，tool可以通过ToolRuntime来修改state的信息。下面我们直接贴官网的两个示例，一个是读取state，一个是写入state：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent, AgentState
from langchain.tools import tool, ToolRuntime


class CustomState(AgentState):
    user_id: str

@tool
def get_user_info(
    runtime: ToolRuntime
) -&amp;gt; str:
    &quot;&quot;&quot;Look up user info.&quot;&quot;&quot;
    user_id = runtime.state[&quot;user_id&quot;]
    return &quot;User is John Smith&quot; if user_id == &quot;user_123&quot; else &quot;Unknown user&quot;

agent = create_agent(
    model=&quot;gpt-5-nano&quot;,
    tools=[get_user_info],
    state_schema=CustomState,
)

result = agent.invoke({
    &quot;messages&quot;: &quot;look up user information&quot;,
    &quot;user_id&quot;: &quot;user_123&quot;
})
print(result[&quot;messages&quot;][-1].content)
# &amp;gt; User is John Smith.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool, ToolRuntime
from langchain_core.runnables import RunnableConfig
from langchain.messages import ToolMessage
from langchain.agents import create_agent, AgentState
from langgraph.types import Command
from pydantic import BaseModel


class CustomState(AgentState):
    user_name: str

class CustomContext(BaseModel):
    user_id: str

@tool
def update_user_info(
    runtime: ToolRuntime[CustomContext, CustomState],
) -&amp;gt; Command:
    &quot;&quot;&quot;Look up and update user info.&quot;&quot;&quot;
    user_id = runtime.context.user_id
    name = &quot;John Smith&quot; if user_id == &quot;user_123&quot; else &quot;Unknown user&quot;
    return Command(update={
        &quot;user_name&quot;: name,
        # update the message history
        &quot;messages&quot;: [
            ToolMessage(
                &quot;Successfully looked up user information&quot;,
                tool_call_id=runtime.tool_call_id
            )
        ]
    })

@tool
def greet(
    runtime: ToolRuntime[CustomContext, CustomState]
) -&amp;gt; str | Command:
    &quot;&quot;&quot;Use this to greet the user once you found their info.&quot;&quot;&quot;
    user_name = runtime.state.get(&quot;user_name&quot;, None)
    if user_name is None:
       return Command(update={
            &quot;messages&quot;: [
                ToolMessage(
                    &quot;Please call the &apos;update_user_info&apos; tool it will get and update the user&apos;s name.&quot;,
                    tool_call_id=runtime.tool_call_id
                )
            ]
        })
    return f&quot;Hello {user_name}!&quot;

agent = create_agent(
    model=&quot;gpt-5-nano&quot;,
    tools=[update_user_info, greet],
    state_schema=CustomState,
    context_schema=CustomContext,
)

agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;greet the user&quot;}]},
    context=CustomContext(user_id=&quot;user_123&quot;),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) Prompt&lt;/h3&gt;
&lt;p&gt;在中间件中访问短期记忆（状态），基于对话历史或自定义状态字段生成动态提示词。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from typing import TypedDict
from langchain.agents.middleware import dynamic_prompt, ModelRequest


class CustomContext(TypedDict):
    user_name: str


def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get the weather in a city.&quot;&quot;&quot;
    return f&quot;The weather in {city} is always sunny!&quot;


@dynamic_prompt
def dynamic_system_prompt(request: ModelRequest) -&amp;gt; str:
    user_name = request.runtime.context[&quot;user_name&quot;]
    system_prompt = f&quot;You are a helpful assistant. Address the user as {user_name}.&quot;
    return system_prompt


agent = create_agent(
    model=&quot;gpt-5-nano&quot;,
    tools=[get_weather],
    middleware=[dynamic_system_prompt],
    context_schema=CustomContext,
)

result = agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What is the weather in SF?&quot;}]},
    context=CustomContext(user_name=&quot;John Smith&quot;),
)
for msg in result[&quot;messages&quot;]:
    msg.pretty_print()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果你还有印象，这个&lt;code&gt;@dynamic_prompt&lt;/code&gt;是专门调整提示词的装饰器，范围比直接before_model或者wrap_model_xxxx更小。&lt;/p&gt;
&lt;h3&gt;(3) After model&lt;/h3&gt;
&lt;p&gt;在agent一章提到了这个部分，是在模型返回消息之后进行操作的钩子，当然也可以用于操作state。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;%%{init: {
  &quot;theme&quot;: &quot;base&quot;,
  &quot;themeVariables&quot;: {
    &quot;fontFamily&quot;: &quot;monospace&quot;
  },
  &quot;flowchart&quot;: {
    &quot;curve&quot;: &quot;basis&quot;
  }
}}%%
graph TD
    S([&quot;__start__&quot;])
    MODEL(model)
    POST(after_model)
    TOOLS(tools)
    E([&quot;__end__&quot;])

    S --&amp;gt; MODEL
    MODEL --&amp;gt; POST
    POST -.-&amp;gt; E
    POST -.-&amp;gt; TOOLS
    TOOLS --&amp;gt; MODEL

    classDef blueHighlight fill:#DBEAFE,stroke:#2563EB,color:#1E3A8A;
    classDef greenHighlight fill:#DCFCE7,stroke:#16A34A,color:#14532D;
    classDef neutral fill:#F3F4F6,stroke:#9CA3AF,stroke-width:2px,color:#374151;

    class S blueHighlight;
    class E blueHighlight;
    class POST greenHighlight;
    class MODEL,TOOLS neutral;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里提供一个示例，一看就懂了。这是触发STOP_WORDS的时候消除所有消息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import RemoveMessage
from langgraph.checkpoint.memory import InMemorySaver
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import after_model
from langgraph.runtime import Runtime


@after_model
def validate_response(state: AgentState, runtime: Runtime) -&amp;gt; dict | None:
    &quot;&quot;&quot;Remove messages containing sensitive words.&quot;&quot;&quot;
    STOP_WORDS = [&quot;password&quot;, &quot;secret&quot;]
    last_message = state[&quot;messages&quot;][-1]
    if any(word in last_message.content for word in STOP_WORDS):
        return {&quot;messages&quot;: [RemoveMessage(id=last_message.id)]}
    return None

agent = create_agent(
    model=&quot;gpt-5-nano&quot;,
    tools=[],
    middleware=[validate_response],
    checkpointer=InMemorySaver(),
)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Naive-RAG 端到端实战：从文档入库到 FastAPI 服务</title><link>https://owen571.top/posts/study/rag/07-naive-rag-%E7%AB%AF%E5%88%B0%E7%AB%AF%E5%AE%9E%E6%88%98/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/07-naive-rag-%E7%AB%AF%E5%88%B0%E7%AB%AF%E5%AE%9E%E6%88%98/</guid><description>把前面的 RAG 基础真正串起来，做一个最小可运行的 Naive-RAG demo：文档切分、向量入库、本地 QA、FastAPI 服务与 Docker 化。</description><pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇可以看作前面 1 到 6 篇的第一次汇总练习。目标不是做一个“很聪明”的系统，而是先把最小闭环打通：文档 -&amp;gt; 切分 -&amp;gt; 嵌入 -&amp;gt; Milvus -&amp;gt; 检索 -&amp;gt; 回答 -&amp;gt; API 服务。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;Naive-RAG 实战&lt;/h1&gt;
&lt;h2&gt;1. 设想和路线&lt;/h2&gt;
&lt;p&gt;我们假设已经有LangChain的基础，RAG理论，Milvus入门的学习了，现在想要做一个简单的demo，目标是整合FastAPI + LangChain，做一个Naive-RAG端到端回答系统，然后用Docker打包发布，从而将部分学习的东西先变成整体，化为内功。&lt;/p&gt;
&lt;p&gt;什么是Naive-RAG？简单来说，这就是最原始、最直接的RAG生成方式，分为“检索+生成”两步走。&lt;/p&gt;
&lt;p&gt;我准备直接把我的强化学习文档作为检索来源，上传。&lt;/p&gt;
&lt;p&gt;AI给我的最小交付建议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;POST /ingest：上传 pdf/md/txt，完成切块、embedding、入库&lt;/li&gt;
&lt;li&gt;POST /ask：输入问题，返回答案、命中文档片段、来源&lt;/li&gt;
&lt;li&gt;GET /health：健康检查&lt;/li&gt;
&lt;li&gt;docker-compose up 能一键启动&lt;/li&gt;
&lt;li&gt;准备一份 20~30 条的小评测集&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;并且评估不能靠“感觉答得不错”，要看4件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;检索命中率：答案所在片段有没有进 top-k&lt;/li&gt;
&lt;li&gt;答案正确性：回答是否接近参考答案&lt;/li&gt;
&lt;li&gt;Groundedness：回答是否被检索到的上下文支持&lt;/li&gt;
&lt;li&gt;延迟/成本：一次问答耗时和 token 开销&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;回到我的资料，我准备用强化学习入门时候的笔记（文件是markdown），特点是有大量的图片，但是文字量本身不大，标题层级明显，章节结构不错。麻烦点在于图片里有关键知识，我决定预处理把图片先换成AI生成的图片描述，然后再处理纯文本的md。&lt;/p&gt;
&lt;p&gt;第一阶段应该把重点放在“让回答严格受笔记约束”。主要是因为，RL算是LLM本来就很熟的领域，如果不限制就直接用自己的知识答了，所以我们第一版要做成grounded_only的设计（以后可以拓展）。&lt;/p&gt;
&lt;h2&gt;2. 文档入库&lt;/h2&gt;
&lt;p&gt;我必须先实现好文档的切分和入库。我采用在项目文件下写一个.env的方法，存入我的三方数据库和key，还有milvus相关配置。&lt;/p&gt;
&lt;p&gt;经过边写边和AI沟通考虑，我将先写下处理单个文档的脚本&lt;code&gt;markdown_splitter.py&lt;/code&gt;，它对外暴露split_markdown_file，返回一个list[Document]（Document是langchain.core里面的一个类，用于处理文件，后续RAG大多依赖于这个）。对文档我们采用两级切分，首先按markdown语法切分，然后再检索其中过长的块，进行第二次Recursive切分。代码如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from pathlib import Path
from langchain_core.documents import Document


def _load_markdown_txt(file_path:Path) -&amp;gt; str:
    &quot;&quot;&quot;
    根据路径加载markdown文件
    &quot;&quot;&quot;
    loader = TextLoader(file_path)
    docs = loader.load()
    return docs[0].page_content

def _split_by_header(text:str) -&amp;gt; list[Document]:
    &quot;&quot;&quot;
    对markdown文件进行第一次切分
    &quot;&quot;&quot;
    markdown_spliter = MarkdownHeaderTextSplitter(
        strip_headers = False,
        headers_to_split_on=[
            (&quot;#&quot;,&quot;h1&quot;),
            (&quot;##&quot;,&quot;h2&quot;),
            (&quot;###&quot;,&quot;h3&quot;)
        ],
        return_each_line = False
    )
    return markdown_spliter.split_text(text)
   

def _split_large_chunks(chunks:list[Document]) -&amp;gt; list[Document]:
    &quot;&quot;&quot;
    对Document列表较长的块进一步切分
    &quot;&quot;&quot;
    recursive_split = RecursiveCharacterTextSplitter(
        # 我当时笔记喜欢半角标点
        separators=[&quot;\n\n&quot;,&quot;\n&quot;,&quot;. &quot;,&quot;, &quot;,&quot; &quot;,&quot;&quot;],
        chunk_size = 300,
        chunk_overlap = 20,
    )

    docs_list = []

    for chunk in chunks:
        if len(chunk.page_content)&amp;gt;300:
            i = recursive_split.split_documents([chunk])
            docs_list.extend(i)
        else:
            docs_list.append(chunk)
    return docs_list


def split_markdown_file(file_path:Path) -&amp;gt; list[Document]:
    &quot;&quot;&quot;
    对外接口，传入md文件路径，返回切分完成的documents
    &quot;&quot;&quot;
    text = _load_markdown_txt(file_path)
    header_chunks = _split_by_header(text)
    final_chunks = _split_large_chunks(header_chunks)
    return final_chunks


def _resolve_debug_target() -&amp;gt; Path:
    &quot;&quot;&quot;默认取 processed 目录下一篇 markdown，方便单独调试 splitter。可以自己改第几个。&quot;&quot;&quot;
    script_path = Path(__file__).resolve()
    project_root = script_path.parent.parent
    processed_dir = project_root / &quot;docs&quot; / &quot;processed&quot;
    md_files = sorted(processed_dir.glob(&quot;*.md&quot;))
    if not md_files:
        raise FileNotFoundError(f&quot;在 {processed_dir} 下没有找到 markdown 文件&quot;)
    return md_files[0]


if __name__ == &quot;__main__&quot;:
    import sys

    if len(sys.argv) &amp;gt; 1:
        target_file = Path(sys.argv[1]).expanduser().resolve()
    else:
        target_file = _resolve_debug_target()

    chunks = split_markdown_file(target_file)
    print(f&quot;调试文件: {target_file}&quot;)
    print(f&quot;总块数: {len(chunks)}&quot;)
    print()

    for i, chunk in enumerate(chunks):
        print(&quot;=&quot; * 10 + f&quot; 第{i}块 &quot; + &quot;=&quot; * 10)
        print(f&quot;长度: {len(chunk.page_content)}&quot;)
        print(f&quot;metadata: {chunk.metadata}&quot;)
        print(&quot;正文预览:&quot;)
        print(chunk.page_content[:400])
        print()

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-24.uOCTcf-M.png&amp;amp;w=1274&amp;amp;h=1038&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;3. chunk加载进库&lt;/h2&gt;
&lt;p&gt;前面我们已经切分完毕，现在写一个&lt;code&gt;docs_to_milvus.py&lt;/code&gt;函数，负责创建需要的Collection并且将chunk调整成适合schema的字典，插入Collection。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from markdown_splitter import split_markdown_file
from pathlib import Path
from pymilvus import MilvusClient, FieldSchema, CollectionSchema, DataType
from dotenv import load_dotenv
import os
from langchain_openai import OpenAIEmbeddings

# 根目录相对设置
script_path = Path(__file__).resolve()
project_path = script_path.parent.parent

# 资源寻路
processed_dir = project_path/&quot;docs&quot;/&quot;processed&quot;
env_path = project_path/&quot;.env&quot;

# 全局变量
load_dotenv(env_path)


# 开始切分docs
# Path.glob(...)返回的不是列表本身，而是一个可迭代对象
print(&quot;=&quot; * 5 + &quot;正在切分md文件&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)
chunk_list = []
for file_path in processed_dir.glob(&quot;*.md&quot;):
    chunk_list.extend(split_markdown_file(file_path))
print(&quot;=&quot; * 5 + &quot;文件切分完成&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)


# 开始连接milvus
# 建立客户端连接
client = MilvusClient(uri = os.environ.get(&quot;MILVUS_URL&quot;))


# 进行嵌入
# 我这里从中转站随便挑了一个text-embedding-3-small，默认嵌入长度是1536
# 进行嵌入
print(&quot;=&quot; * 5 + &quot;正在嵌入chunk&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)
embedding_model = OpenAIEmbeddings(
    model=&quot;text-embedding-3-small&quot;,
    api_key=os.environ.get(&quot;QIHANG_API&quot;),
    base_url=os.environ.get(&quot;QIHANG_BASE_URL&quot;),
)

# 遍历 chunk list，将 page_content 放入列表，批量嵌入
page_content_list = [chunk.page_content for chunk in chunk_list]

vectors = embedding_model.embed_documents(page_content_list)

print(&quot;=&quot; * 5 + &quot;嵌入完成&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)

# print(f&quot;chunk 数量: {len(page_content_list)}&quot;)
# print(f&quot;向量数量: {len(vectors)}&quot;)
# print(f&quot;单个向量维度: {len(vectors[0])}&quot;)


# 动态得到需要嵌入的维度
vector_dim = len(vectors[0])

print(&quot;=&quot; * 5 + &quot;开始构造Collection&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)
# 动态判断text所需要最大长度
# 注意不是字符数而是token数，要encode一下
max_length_text = 0
max_length_title = 0
for chunk in chunk_list:
    max_length_text = max(max_length_text,len(chunk.page_content.encode(&quot;utf-8&quot;)))
    max_length_title = max(max_length_title,len(chunk.metadata.get(&quot;h1&quot;,&quot;&quot;).encode(&quot;utf-8&quot;)),len(chunk.metadata.get(&quot;h2&quot;,&quot;&quot;).encode(&quot;utf-8&quot;)),len(chunk.metadata.get(&quot;h3&quot;,&quot;&quot;).encode(&quot;utf-8&quot;)))



# 定义Collection的schema
fields = [
    FieldSchema(
        name = &quot;id&quot;,
        dtype = DataType.INT64,
        description=&quot;作为主键的id&quot;,
        is_primary = True,
        auto_id = True
    ),
    FieldSchema(
        name = &quot;vector&quot;,
        dtype = DataType.FLOAT_VECTOR, 
        # dim必须和嵌入模型一致
        dim = vector_dim,
        description = &quot;存储chunk的向量&quot;
    ),
    FieldSchema(
        name = &quot;text&quot;,
        dtype = DataType.VARCHAR,
        # VARCHAR的最大长度
        max_length = max_length_text,
        description = &quot;原始page_content文本&quot;
    ),
    FieldSchema(
        name = &quot;h1&quot;,
        dtype = DataType.VARCHAR,
        max_length = max_length_title,
        description = &quot;一级标题&quot;
    ),
    FieldSchema(
        name = &quot;h2&quot;,
        dtype = DataType.VARCHAR,
        max_length = max_length_title,
        description = &quot;二级标题&quot;
    ),
    FieldSchema(
        name = &quot;h3&quot;,
        dtype = DataType.VARCHAR,
        max_length = max_length_title,
        description = &quot;三级标题&quot;
    )
]

schema = CollectionSchema(fields)

# 创建Collection，注意去重
if client.has_collection(&quot;RL_docs&quot;):
    client.drop_collection(&quot;RL_docs&quot;)

# 创建collection
client.create_collection(
    collection_name=&quot;RL_docs&quot;, 
    schema=schema
)

# 给Collection建立索引
index_params = client.prepare_index_params()
index_params.add_index(
    field_name=&quot;vector&quot;,
    index_type=&quot;FLAT&quot;,
    index_name=&quot;vector_index&quot;,
    metric_type=&quot;COSINE&quot;,
    params={},
)
client.create_index(
    collection_name=&quot;RL_docs&quot;, index_params=index_params
)


print(&quot;=&quot; * 5 + &quot;Collection构造完成&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)

# print(len(data) == len(vectors))

# 开始构建Milvus insert data并插入
# data里面是Document的list，vectors是向量，现在对齐遍历
# 我们最终需要一个列表，里面有所有对应chunk数目的字典数，字典对应schema字段

print(&quot;=&quot; * 5 + &quot;数据入库中&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)
records = []
for chunk, vec in zip(chunk_list, vectors):
    record = {
        &quot;vector&quot;: vec,
        &quot;text&quot;: chunk.page_content,
        &quot;h1&quot;: chunk.metadata.get(&quot;h1&quot;, &quot;&quot;),
        &quot;h2&quot;: chunk.metadata.get(&quot;h2&quot;, &quot;&quot;),
        &quot;h3&quot;: chunk.metadata.get(&quot;h3&quot;, &quot;&quot;),
    }
    records.append(record)

print(&quot;=&quot; * 5 + &quot;入库已完成&quot; + &quot;=&quot; * 5 + &quot;\n&quot;)
client.insert(
    collection_name = &quot;RL_docs&quot;,
    data = records
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 本地QA&lt;/h2&gt;
&lt;p&gt;为了先看看Naive-RAG是否已经形成了完整的通路，我们现在本地用聊天模型试试效果。代码&lt;code&gt;qa_pipeline.py&lt;/code&gt;和运行结果如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 本地问答测试
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
from pathlib import Path
from pymilvus import MilvusClient
from langchain_openai import OpenAIEmbeddings
import os

# 加载项目相对路径
script_path = Path(__file__).resolve()
project_path = script_path.parent.parent
env_path = project_path/&quot;.env&quot;

# 加载全局配置
load_dotenv(env_path)
COLLECTION_NAME = &quot;RL_docs&quot;
TOP1_THRESHOLD = 0.45
HIT_THRESHOLD = 0.40

# 连接数据库
client = MilvusClient(uri = os.environ.get(&quot;MILVUS_URL&quot;))


# 加载embedding模型
embeddings_model = OpenAIEmbeddings(
    model = &quot;text-embedding-3-small&quot;,
    api_key = os.environ.get(&quot;QIHANG_API&quot;),
    base_url = os.environ.get(&quot;QIHANG_BASE_URL&quot;)
)

# 加载聊天模型
chat_model = init_chat_model(
    model = &quot;openai:gpt-4o-mini&quot;,
    temperature=0.5,
    timeout=30,
    max_retries=6, 
    api_key = os.environ.get(&quot;QIHANG_API&quot;),
    base_url = os.environ.get(&quot;QIHANG_BASE_URL&quot;)
)


# 加载Collection
if not client.has_collection(COLLECTION_NAME):
    raise ValueError(f&quot;Collection {COLLECTION_NAME} 不存在，请先运行入库脚本&quot;)

client.load_collection(collection_name=COLLECTION_NAME)


def retrieve(question, k = 4):
    &quot;&quot;&quot;
    查询并返回topk结果
    &quot;&quot;&quot;
    query_vector = embeddings_model.embed_query(question)
    result = client.search(
        collection_name = COLLECTION_NAME,
        data = [query_vector],
        limit = k,
        output_fields=[&quot;text&quot;,&quot;h1&quot;,&quot;h2&quot;,&quot;h3&quot;],
        anns_field = &quot;vector&quot;,
        search_params={&quot;metric_type&quot;: &quot;COSINE&quot;, &quot;params&quot;: {}},
    )
    return result[0]


def filter_hits(hits, top1_threshold=TOP1_THRESHOLD, hit_threshold=HIT_THRESHOLD):
    &quot;&quot;&quot;
    根据相似度分数过滤检索结果:
    1. top1 太低时直接视为未命中
    2. 只保留达到最低阈值的片段
    &quot;&quot;&quot;
    if not hits:
        return []

    top1_score = hits[0].get(&quot;distance&quot;, 0.0)
    if top1_score &amp;lt; top1_threshold:
        return []

    return [hit for hit in hits if hit.get(&quot;distance&quot;, 0.0) &amp;gt;= hit_threshold]


def build_context(hits):
    &quot;&quot;&quot;
    将 Milvus 命中结果拼成带编号、标题、正文的上下文
    &quot;&quot;&quot;
    blocks = []

    # 从1开始编号
    for idx, hit in enumerate(hits, start=1):
        entity = hit.get(&quot;entity&quot;, {})

        text = entity.get(&quot;text&quot;, &quot;&quot;).strip()
        h1 = entity.get(&quot;h1&quot;, &quot;&quot;).strip()
        h2 = entity.get(&quot;h2&quot;, &quot;&quot;).strip()
        h3 = entity.get(&quot;h3&quot;, &quot;&quot;).strip()

        title_parts = [part for part in [h1, h2, h3] if part]
        title_path = &quot; &amp;gt; &quot;.join(title_parts) if title_parts else &quot;未标注标题&quot;

        block = (
            f&quot;[片段{idx}]\n&quot;
            f&quot;标题：{title_path}\n&quot;
            f&quot;内容：{text}&quot;
        )
        blocks.append(block)

    return &quot;\n\n&quot;.join(blocks)


def build_sources(hits):
    &quot;&quot;&quot;
    对检索到的内容，生成标注字符串
    &quot;&quot;&quot;
    sources = []

    for idx, hit in enumerate(hits, start=1):
        entity = hit.get(&quot;entity&quot;, {})
        h1 = entity.get(&quot;h1&quot;, &quot;&quot;).strip()
        h2 = entity.get(&quot;h2&quot;, &quot;&quot;).strip()
        h3 = entity.get(&quot;h3&quot;, &quot;&quot;).strip()

        title_parts = [part for part in [h1, h2, h3] if part]
        title_path = &quot; &amp;gt; &quot;.join(title_parts) if title_parts else &quot;未标注标题&quot;

        sources.append(f&quot;[片段{idx}] {title_path}&quot;)

    return &quot;【资料来源】\n&quot; + &quot;\n&quot;.join(sources)


def answer(question):
    &quot;&quot;&quot;
    结合搜索结果回答问题
    &quot;&quot;&quot;
    SYSTEM_MESSAGE = &quot;&quot;&quot;
    你是一个基于强化学习资料回答问题的助手，只能根据给定片段回答。
    如果资料没有覆盖，就明确回答“资料未覆盖”。
    不要使用外部知识，不要编造来源。

    以下是检索到的资料片段：
    &quot;&quot;&quot;
    raw_hits = retrieve(question)
    hits = filter_hits(raw_hits)
    if not hits:
        return &quot;资料未覆盖。&quot;

    context = build_context(hits)
    sources = build_sources(hits)
    result = chat_model.invoke(
    [
        {&quot;role&quot;:&quot;system&quot;,&quot;content&quot;:SYSTEM_MESSAGE + context},
        {&quot;role&quot;:&quot;user&quot;,&quot;content&quot;:question}
    ]
    )
    return result.content + &quot;\n\n&quot; + sources


if __name__ == &quot;__main__&quot;:
    question = input().strip()
    result = answer(question)
    print(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-25.ByLxdM2y.png&amp;amp;w=1322&amp;amp;h=1222&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;5. FastAPI&lt;/h2&gt;
&lt;p&gt;我们在Naive-RAG下面再创建一个文件夹叫做app，并存放逻辑，将其做成一个最小但是像项目的结构。&lt;/p&gt;
&lt;p&gt;原来的 qa_pipeline.py
-&amp;gt; 被拆成 rag_service.py + schemas.py + main.py&lt;/p&gt;
&lt;p&gt;原来脚本里的 os.environ.get(...)
-&amp;gt; 收到 config.py&lt;/p&gt;
&lt;p&gt;原来的 if &lt;strong&gt;name&lt;/strong&gt; == &quot;&lt;strong&gt;main&lt;/strong&gt;&quot;:
-&amp;gt; 变成了 FastAPI 的 /ask 路由&lt;/p&gt;
&lt;p&gt;原来“打印字符串结果”
-&amp;gt; 变成结构化 API 响应&lt;/p&gt;
&lt;p&gt;这样使得结果更适应FastAPI。&lt;/p&gt;
&lt;p&gt;一个一个来，我们先看看&lt;code&gt;app/rag_service.py&lt;/code&gt;，它是将之前的所有流程都封装了进去，对外暴露一个RAGService类。当然，逻辑和前面都是一样。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.chat_models import init_chat_model
from langchain_openai import OpenAIEmbeddings
from pymilvus import MilvusClient

from .config import Settings
from .schemas import AskResponse, SourceItem


SYSTEM_MESSAGE = &quot;&quot;&quot;
你是一个基于强化学习资料回答问题的助手，只能根据给定片段回答。
如果资料没有覆盖，就明确回答“资料未覆盖”。
不要使用外部知识，不要编造来源，也不要假装自己看过未提供的资料。

以下是检索到的资料片段：
&quot;&quot;&quot;.strip()


# 用一个RAGSerice类，封装整个流程
class RAGService:
    def __init__(self, settings: Settings):
        self.settings = settings
        self.client = MilvusClient(uri=settings.milvus_url)

        if not self.client.has_collection(settings.collection_name):
            raise ValueError(
                f&quot;Collection {settings.collection_name} 不存在，请先运行入库脚本&quot;
            )

        self.client.load_collection(collection_name=settings.collection_name)

        self.embeddings_model = OpenAIEmbeddings(
            model=settings.embedding_model,
            api_key=settings.qihang_api,
            base_url=settings.qihang_base_url,
        )

        self.chat_model = init_chat_model(
            model=settings.chat_model,
            model_provider=&quot;openai&quot;,
            temperature=settings.chat_temperature,
            timeout=settings.chat_timeout,
            max_retries=settings.chat_max_retries,
            api_key=settings.qihang_api,
            base_url=settings.qihang_base_url,
        )

    def retrieve(self, question: str, k: int | None = None) -&amp;gt; list[dict]:
        query_vector = self.embeddings_model.embed_query(question)
        result = self.client.search(
            collection_name=self.settings.collection_name,
            data=[query_vector],
            limit=k or self.settings.default_k,
            output_fields=[&quot;text&quot;, &quot;h1&quot;, &quot;h2&quot;, &quot;h3&quot;],
            anns_field=&quot;vector&quot;,
            search_params={&quot;metric_type&quot;: &quot;COSINE&quot;, &quot;params&quot;: {}},
        )
        return result[0]

    def filter_hits(self, hits: list[dict]) -&amp;gt; list[dict]:
        if not hits:
            return []

        top1_score = hits[0].get(&quot;distance&quot;, 0.0)
        if top1_score &amp;lt; self.settings.top1_threshold:
            return []

        return [
            hit
            for hit in hits
            if hit.get(&quot;distance&quot;, 0.0) &amp;gt;= self.settings.hit_threshold
        ]

    def build_context(self, hits: list[dict]) -&amp;gt; str:
        blocks: list[str] = []
        for idx, hit in enumerate(hits, start=1):
            entity = hit.get(&quot;entity&quot;, {})
            text = entity.get(&quot;text&quot;, &quot;&quot;).strip()
            h1 = entity.get(&quot;h1&quot;, &quot;&quot;).strip()
            h2 = entity.get(&quot;h2&quot;, &quot;&quot;).strip()
            h3 = entity.get(&quot;h3&quot;, &quot;&quot;).strip()

            title_parts = [part for part in [h1, h2, h3] if part]
            title_path = &quot; &amp;gt; &quot;.join(title_parts) if title_parts else &quot;未标注标题&quot;

            blocks.append(
                f&quot;[片段{idx}]\n&quot;
                f&quot;标题：{title_path}\n&quot;
                f&quot;内容：{text}&quot;
            )

        return &quot;\n\n&quot;.join(blocks)

    def build_sources(self, hits: list[dict]) -&amp;gt; list[SourceItem]:
        sources: list[SourceItem] = []
        seen_titles: set[str] = set()

        for idx, hit in enumerate(hits, start=1):
            entity = hit.get(&quot;entity&quot;, {})
            h1 = entity.get(&quot;h1&quot;, &quot;&quot;).strip()
            h2 = entity.get(&quot;h2&quot;, &quot;&quot;).strip()
            h3 = entity.get(&quot;h3&quot;, &quot;&quot;).strip()

            title_parts = [part for part in [h1, h2, h3] if part]
            title_path = &quot; &amp;gt; &quot;.join(title_parts) if title_parts else &quot;未标注标题&quot;

            if title_path in seen_titles:
                continue

            seen_titles.add(title_path)
            sources.append(
                SourceItem(
                    snippet_id=f&quot;片段{idx}&quot;,
                    title=title_path,
                    score=round(hit.get(&quot;distance&quot;, 0.0), 4),
                )
            )

        return sources

    def answer(self, question: str, k: int | None = None) -&amp;gt; AskResponse:
        raw_hits = self.retrieve(question, k)
        hits = self.filter_hits(raw_hits)

        if not hits:
            return AskResponse(answer=&quot;资料未覆盖。&quot;, covered=False, sources=[])

        context = self.build_context(hits)
        response = self.chat_model.invoke(
            [
                {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: f&quot;{SYSTEM_MESSAGE}\n\n{context}&quot;},
                {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: question},
            ]
        )

        return AskResponse(
            answer=response.content.strip(),
            covered=True,
            sources=self.build_sources(hits),
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后是&lt;code&gt;app/config.py&lt;/code&gt;，这里直接对外暴露一个settings实例，以后直接调用即可。我们把默认设置和环境变量都在这里读取了先。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pathlib import Path

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

app_path = Path(__file__).resolve()
project_path = app_path.parent.parent
env_path = project_path / &quot;.env&quot;


class Settings(BaseSettings):
    milvus_url: str = Field(validation_alias=&quot;MILVUS_URL&quot;)
    qihang_api: str = Field(validation_alias=&quot;QIHANG_API&quot;)
    qihang_base_url: str = Field(validation_alias=&quot;QIHANG_BASE_URL&quot;)

    collection_name: str = &quot;RL_docs&quot;
    embedding_model: str = &quot;text-embedding-3-small&quot;
    chat_model: str = &quot;gpt-4o-mini&quot;
    chat_temperature: float = 0.5
    chat_timeout: int = 30
    chat_max_retries: int = 6
    top1_threshold: float = 0.45
    hit_threshold: float = 0.40
    default_k: int = 4

    model_config = SettingsConfigDict(
        env_file=env_path,
        env_file_encoding=&quot;utf-8&quot;,
        extra=&quot;ignore&quot;,
    )


settings = Settings()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以看到，这里用到了之前都没有用过的&lt;code&gt;from pydantic_settings import BaseSettings, SettingsConfigDict&lt;/code&gt;。其实这属于专门用来写配置类的Pydantic基类。下面的语法，就是给BaseSettings配置行为用的，他告诉BaseSettings去哪个文件找配置，然后按什么编码读文件，遇到没有声明过的环境变量怎么处理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model_config = SettingsConfigDict(
    env_file=env_path,
    env_file_encoding=&quot;utf-8&quot;,
    extra=&quot;ignore&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以我们在Settings类中定义了milvus_url等，并把&lt;code&gt;validation_alias&lt;/code&gt;设置成了MILVUS_URL，跟环境变量里面写的对上了。&lt;/p&gt;
&lt;p&gt;然后，因为我们要做的FastAPI接口了，用户不再是“终端里的一句话”，而是一个HTTP请求。所以程序必须知道这个请求体的规范，这就要请app/schemas.py登场了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel, Field


class AskRequest(BaseModel):
    question: str = Field(..., min_length=1, description=&quot;用户提问内容&quot;)
    k: int | None = Field(default=None, ge=1, le=10, description=&quot;返回的检索片段数量&quot;)


class SourceItem(BaseModel):
    snippet_id: str
    title: str
    score: float


class AskResponse(BaseModel):
    answer: str
    covered: bool
    sources: list[SourceItem] = Field(default_factory=list)


class HealthResponse(BaseModel):
    status: str
    collection_name: str
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;里面定义了四个类。&lt;code&gt;AskRequest&lt;/code&gt;是定义了/ask请求应该接受什么样的请求体，包括question用min_length = 1表示必填，k可选（我们在config中已经定义默认值为4了，不需要用户一定要每次请求提供）；&lt;code&gt;SourceItem&lt;/code&gt;适用于定义单条来源信息的标准结构，因为每次回答我们还会返回一个来源列表，包含片段编号、标题、分数；&lt;code&gt;AskResponse&lt;/code&gt;用于定义/ask最终返回给调用方的数据结构，包含答案、是否覆盖、来源列表；&lt;code&gt;HealthResponse&lt;/code&gt;就是给/health用的。&lt;/p&gt;
&lt;p&gt;其实可以返回的时候全写字典，但是这样写对FastAPI 文档自动生成更友好，而且更容易维护。我们可以看到，rag_service.py就引用了这些类来作为信息的包装。&lt;/p&gt;
&lt;p&gt;最后，我们来看看&lt;code&gt;app/main.py&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from contextlib import asynccontextmanager

from fastapi import FastAPI, Request

from .config import settings
from .rag_service import RAGService
from .schemas import AskRequest, AskResponse, HealthResponse


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.rag_service = RAGService(settings)
    yield


app = FastAPI(
    title=&quot;Naive RAG API&quot;,
    version=&quot;0.1.0&quot;,
    lifespan=lifespan,
)


@app.get(&quot;/health&quot;, response_model=HealthResponse)
async def health() -&amp;gt; HealthResponse:
    return HealthResponse(status=&quot;ok&quot;, collection_name=settings.collection_name)


@app.post(&quot;/ask&quot;, response_model=AskResponse)
async def ask(payload: AskRequest, request: Request) -&amp;gt; AskResponse:
    rag_service: RAGService = request.app.state.rag_service
    return rag_service.answer(payload.question, payload.k)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个就是FastAPI的主入口，我们已经知道config.py 是配置入口，schemas.py 是接口协议，rag_service.py 是真正干活的业务层，那main.py的指责就很单纯了，它负责把HTTP请求接进来，再转交给RAGService。&lt;/p&gt;
&lt;p&gt;先导入全局配置settings，导入fastapi的请求和对象，导入业务类RAGService，导入schemas中的各种规范类。&lt;/p&gt;
&lt;p&gt;然后，用&lt;code&gt;lifespan&lt;/code&gt;初始化一个全局可复用的RAGService，传入settings参数，生成并挂载应用级别全局对象&lt;code&gt;app.state.rag_service&lt;/code&gt;。然后用&lt;code&gt;yield&lt;/code&gt;表示初始化完成，可以开始处理请求（注意后面做FastAPI实例的时候传入一下lifespan）。然后，定义了一个健康检查路由&lt;code&gt;/health&lt;/code&gt;，先用GET /health看看服务是否健康，并告诉连接的是哪个collection。&lt;/p&gt;
&lt;p&gt;然后，就是重头戏/ask接口了，定义了一个用于POST /ask
的，按照AskRequest包装起来，调用RAGService的answer，返回。&lt;/p&gt;
&lt;p&gt;使用&lt;code&gt;uvicorn app.main:app --reload&lt;/code&gt;启动之后，我们直接访问文档页，&lt;code&gt;http://127.0.0.1:8000/docs#/&lt;/code&gt;，发现就可以看到自己定义的接口和规范：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-26.Dhan40vY.png&amp;amp;w=1532&amp;amp;h=1698&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;执行健康检查，状态没问题，并显示出Collection名：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-27.C0Zwr7bz.png&amp;amp;w=1474&amp;amp;h=1730&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;执行ask，查看RAG链路是否正确：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-28.yRDll5vT.png&amp;amp;w=1472&amp;amp;h=1310&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-29.CfmmN231.png&amp;amp;w=1474&amp;amp;h=1936&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;再看一眼我们定义的Schemas：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-30.CJvsBWlu.png&amp;amp;w=1460&amp;amp;h=1922&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;不过另外要提醒一下，glob + 读文件 + 切分 + 入库依旧在&lt;code&gt;docs_to_milvus.py&lt;/code&gt;里面，没有塞进FastAPI在线请求，不然会很重耦合（并且每次启动都要嵌入文件，很慢）。&lt;/p&gt;
&lt;h2&gt;6. Docker打包&lt;/h2&gt;
&lt;p&gt;在打包发布之前，我们先整理依赖。这个项目比较简单，可以记住用过哪些依赖。以后的开发最后都单独建一个环境，然后把用到的包都给freeze一下。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fastapi==0.135.1
uvicorn==0.42.0
pydantic==2.12.5
pydantic-settings==2.13.1
langchain==1.2.13
langchain-openai==1.1.12
pymilvus==2.6.11
python-dotenv==1.2.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后，我们写Dockerfile如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir --upgrade pip \
    &amp;amp;&amp;amp; pip install --no-cache-dir -r requirements.txt

COPY app ./app

EXPOSE 8000

CMD [&quot;uvicorn&quot;, &quot;app.main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这版本的Dockerfile，将FastAPI问答服务包装成一个可运行镜像，打包app在线服务层（不包含离线注入脚本）。以一个已经装好 Python 3.11 的轻量 Linux 镜像作为基础，&lt;code&gt;ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1&lt;/code&gt;代表不生产&lt;code&gt;.pyc&lt;/code&gt;字节码缓存文件；&lt;code&gt;PYTHONUNBUFFERED=1&lt;/code&gt;让python日志立刻输出；&lt;code&gt;WORKDIR /app&lt;/code&gt;表示设置容器内的工作目录；&lt;code&gt;COPY requirements.txt .&lt;/code&gt;是把&lt;code&gt;requirements.txt&lt;/code&gt;复制到&lt;code&gt;/app&lt;/code&gt;下；然后我们RUN一下环境依赖（记得加&lt;code&gt;--no-cache-dir&lt;/code&gt; ）；&lt;code&gt;COPY app ./app&lt;/code&gt;表示将本地的app/目录复制到容器里&lt;code&gt;/app/app&lt;/code&gt;，这里没有复制docs和scripts;&lt;code&gt;EXPOSE 8000&lt;/code&gt;表好似暴露容器的8000端口到宿主机，最后用&lt;code&gt;CMD&lt;/code&gt;命令表示启动的命令。&lt;/p&gt;
&lt;p&gt;现在，来到项目根目录，就可以用&lt;code&gt;docker build -t owen571/naive-rag:0.1.0 .&lt;/code&gt;根据file文件构建起来一个镜像。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-31.Bg2V4mZV.png&amp;amp;w=1502&amp;amp;h=498&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;然后我们用&lt;code&gt;docker run --rm -p 8000:8000 --env-file .env owen571/naive-rag:0.1.0&lt;/code&gt;来确认是否构建镜像成功即可。这里还有几个坑：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;不能继续使用localhost，要是用&lt;code&gt;MILVUS_URL=http://host.docker.internal:19530&lt;/code&gt;这样的说法。&lt;/li&gt;
&lt;li&gt;环境变量不需要引号，等号两边不能有空格。docker的-env-file检查比load_dotenv严格一点&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如下启动完成了（由于没写名字，被临时取名了）
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-32.B-6OTRsA.png&amp;amp;w=2484&amp;amp;h=142&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-33.BoMX4519.png&amp;amp;w=2544&amp;amp;h=696&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;启动之后，我们会发现功能都是正常的。&lt;/p&gt;
&lt;h2&gt;7. 发布&lt;/h2&gt;
&lt;p&gt;登录时候，直接推送如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker push owen571/naive-rag:0.1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-34.zZ5jalm6.png&amp;amp;w=1532&amp;amp;h=778&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>RLHF 奠基论文：Helpful &amp; Harmless Assistant 速记</title><link>https://owen571.top/posts/study/reinforce-learning/09-rlhf-%E5%A5%A0%E5%9F%BA%E8%AE%BA%E6%96%87%E9%80%9F%E8%AE%B0/</link><guid isPermaLink="true">https://owen571.top/posts/study/reinforce-learning/09-rlhf-%E5%A5%A0%E5%9F%BA%E8%AE%BA%E6%96%87%E9%80%9F%E8%AE%B0/</guid><description>回看 Anthropic 早期 RLHF 代表作，把 Helpful / Harmless 助手训练流程和数据路径梳理一遍。</description><pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;由于从开学到现在主要研究的方向均为Agent和他们之间的合作，因此现在将对这个方向进行一些稍微深入的挖掘。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一. 奠基与探索&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;Training a Helpful and Harmless Assistant with RLHF&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;仅仅模仿, 无法让模型在多元且冲突的人类价值观中做出选择. 在这样的背景下, &lt;em&gt;Training a Helpful and Harmless Assistant with RLHF&lt;/em&gt; &lt;a href=&quot;http://arxiv.org/abs/2204.05862&quot;&gt;论文&lt;/a&gt; 作为Anthropic公司的早期工作, 系统阐述了RLHF如何弥补这一关键的鸿沟, 将模型训练从&quot;模仿学习&quot;的问题转变成了&quot;优化目标&quot;问题.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RLHF ( Reinforcement learning from human feedback), 基于人类反馈的强化学习&lt;/strong&gt;, 本文提出这种技术来帮助Agent做出符合人类偏好的选择, 已得到一个在帮助性 ( Helpful ) 和无害性 ( Harmless )之间取得最佳平衡的语言模型助手.&lt;/p&gt;
&lt;p&gt;其数据集的收集过程和模型的训练过程, 可以用下图来总结:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;第一条线路以预训练模型为起点 ( PLM ), 根据互联网上的比较数据得到预训练偏好模型 ( PMP ), 然后再通过人类返回的比较数据集上微调, 得到偏好模型 ( PM ).&lt;/li&gt;
&lt;li&gt;第二条线路再以PLM为起点, 根据提示数据, 将52B模型的蒸馏给更小的模型, 独立训练不同参数量的模型.&lt;/li&gt;
&lt;li&gt;后者的模型会作为强化学习的初始策略模型, 然后以PM模型作为奖励模型, 基于&lt;strong&gt;PPO&lt;/strong&gt;的方法进行强化学习训练; 根据得到的强化学习模型, 生成新的对比数据, 人工标注后重新训练PM, 然后再训练强化学习模型, 如此迭代.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251018224720.huUC37E6.png&amp;amp;w=1866&amp;amp;h=1022&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;有了对图片大致的了解, 接下来来详细介绍这个过程.&lt;/p&gt;
&lt;h2&gt;(1) 数据收集&lt;/h2&gt;
&lt;p&gt;团队选择直观且熟悉的任务, 从用户反馈界面收集反馈. 在Helpful的界面中, 让众包工作者选择更好的回复, 而在Harmless中 ( 红队 ), 工作者会激发其有害的回应, 选择更差的回复. 这样构成了人类偏好数据集, 下图分别为数据集的说明, 工作者看到的界面:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251019105637.BeTafXVY.png&amp;amp;w=1284&amp;amp;h=1390&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251019105739.DoUSH7rS.png&amp;amp;w=1483&amp;amp;h=1087&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;部署在这个界面上的模型有三类, 来对比数据监控进展且提高数据的多样性 ( 也许 ). 并用这三类数据集的结果, 把数据也划分成了三种分布, 前两个是静态数据集, 而最后一个不断迭代 ( 即文中的Online数据集, 后面证明效果确实比较好, 但是不是这次学习关注的重点 ). 最终通过Elo Score来比较模型性能.&lt;/p&gt;
&lt;h2&gt;(2) 偏好模型预训练&lt;/h2&gt;
&lt;p&gt;PMP&lt;/p&gt;
&lt;h2&gt;(3) 偏好模型&lt;/h2&gt;
&lt;p&gt;PM&lt;/p&gt;
&lt;h2&gt;(4) 迭代&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251013141950.DiSIiQvM.png&amp;amp;w=1471&amp;amp;h=757&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    subgraph A [范式一: 偏好对齐训练]
        direction LR
        subgraph A1 [数据基础]
            A1_1[&quot;静态偏好数据集&quot;]
        end
        
        A2[&quot;偏好奖励微调(PBRFT)&quot;]
        A3[&quot;目标: 模仿人类偏好&quot;]
        
        A1_1 -- 输入 --&amp;gt; A2
        A2 -- 导向 --&amp;gt; A3
    end

    subgraph B [范式二: 智能体强化学习]
        direction LR
        subgraph B1 [数据基础]
            B1_1[&quot;动态环境交互&quot;]
        end
        
        B2[&quot;智能体强化学习(Agentic RL)&quot;]
        B3[&quot;目标: 掌握复杂技能&quot;]
        
        B1_1 -- 通过 --&amp;gt; B2
        B2 -- 导向 --&amp;gt; B3
    end

    A -- 范式演进 --&amp;gt; B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;行为克隆👉监督微调👉强化微调👉基于偏好RFT👉智能体强化学习&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fpasted-image-20251019203254.CPboVNrb.png&amp;amp;w=2125&amp;amp;h=1143&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>LangChain 核心组件 05：Streaming</title><link>https://owen571.top/posts/study/langchain/07-streaming/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/07-streaming/</guid><description>当模型和 Agent 真正跑起来时，如何把 tokens、工具执行进度和自定义状态实时流出来。</description><pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Streaming 我刻意放在 Memory 后面，因为它不是“先学会调用模型”的必修项，而是当你的应用开始像真实产品那样运行时，才最能体现价值。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;LangChain 实现了一套流式传输系统，用于呈现实时更新。&lt;/p&gt;
&lt;p&gt;流式传输对于提升基于大语言模型构建的应用程序的响应能力至关重要。通过逐步展示输出内容，即便在完整响应生成完成之前，流式传输也能显著改善用户体验（UX），尤其是在应对大语言模型存在延迟的情况下。&lt;/p&gt;
&lt;p&gt;借助 LangChain 流式传输可实现以下功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;流式传输智能体执行进度—— 在智能体每一步执行后获取状态更新。&lt;/li&gt;
&lt;li&gt;流式传输大语言模型令牌—— 在语言模型令牌生成时实时流式传输。&lt;/li&gt;
&lt;li&gt;流式传输思考 / 推理令牌—— 在模型生成推理内容时实时呈现。&lt;/li&gt;
&lt;li&gt;流式传输自定义更新—— 发送用户自定义信号（例如 &quot;Fetched 10/100 records&quot;）。&lt;/li&gt;
&lt;li&gt;流式传输多种模式—— 可选择 updates（智能体执行进度）、messages（大语言模型令牌 + 元数据）或 custom（任意用户数据）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 流式模式选择&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;updates：在每个智能体步骤后流式传输状态更新。若在同一步骤中产生多次更新（例如运行多个节点），这些更新将分别进行流式传输。&lt;/li&gt;
&lt;li&gt;messages：从调用了大语言模型的任意图节点中，流式传输(token, metadata)元组。&lt;/li&gt;
&lt;li&gt;custom：使用流式写入器从图节点内部流式传输自定义数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) update与Agent进程&lt;/h3&gt;
&lt;p&gt;带工具的Agent的信息流动，可以简化为经过三次update。首先。LLM node会返回带有工具调用的AIMessage，然后Tool Node会返回带有工具执行结果的ToolMessage（当然，这里也可以是Command，详细的信息我们在后面的ToolNode再学），最后LLM node再做最终的AI response：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent


def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;

    return f&quot;It&apos;s always sunny in {city}!&quot;

agent = create_agent(
    model=&quot;gpt-5-nano&quot;,
    tools=[get_weather],
)
for chunk in agent.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What is the weather in SF?&quot;}]},
    stream_mode=&quot;updates&quot;,
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;updates&quot;:
        for step, data in chunk[&quot;data&quot;].items():
            print(f&quot;step: {step}&quot;)
            print(f&quot;content: {data[&apos;messages&apos;][-1].content_blocks}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到的输出类似这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;step: model
content: [{&apos;type&apos;: &apos;tool_call&apos;, &apos;name&apos;: &apos;get_weather&apos;, &apos;args&apos;: {&apos;city&apos;: &apos;San Francisco&apos;}, &apos;id&apos;: &apos;call_OW2NYNsNSKhRZpjW0wm2Aszd&apos;}]

step: tools
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &quot;It&apos;s always sunny in San Francisco!&quot;}]

step: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos;It&apos;s always sunny in San Francisco!&apos;}]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) messages与LLM token&lt;/h3&gt;
&lt;p&gt;若要流式传输大语言模型生成的令牌，请使用stream_mode=&quot;messages&quot;。下方你可以看到智能体流式调用工具的输出以及最终响应：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent


def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;

    return f&quot;It&apos;s always sunny in {city}!&quot;

agent = create_agent(
    model=&quot;gpt-5-nano&quot;,
    tools=[get_weather],
)
for chunk in agent.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What is the weather in SF?&quot;}]},
    stream_mode=&quot;messages&quot;,
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;messages&quot;:
        token, metadata = chunk[&quot;data&quot;]
        print(f&quot;node: {metadata[&apos;langgraph_node&apos;]}&quot;)
        print(f&quot;content: {token.content_blocks}&quot;)
        print(&quot;\n&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node: model
content: [{&apos;type&apos;: &apos;tool_call_chunk&apos;, &apos;id&apos;: &apos;call_vbCyBcP8VuneUzyYlSBZZsVa&apos;, &apos;name&apos;: &apos;get_weather&apos;, &apos;args&apos;: &apos;&apos;, &apos;index&apos;: 0}]


node: model
content: [{&apos;type&apos;: &apos;tool_call_chunk&apos;, &apos;id&apos;: None, &apos;name&apos;: None, &apos;args&apos;: &apos;{&quot;&apos;, &apos;index&apos;: 0}]


node: model
content: [{&apos;type&apos;: &apos;tool_call_chunk&apos;, &apos;id&apos;: None, &apos;name&apos;: None, &apos;args&apos;: &apos;city&apos;, &apos;index&apos;: 0}]


node: model
content: [{&apos;type&apos;: &apos;tool_call_chunk&apos;, &apos;id&apos;: None, &apos;name&apos;: None, &apos;args&apos;: &apos;&quot;:&quot;&apos;, &apos;index&apos;: 0}]


node: model
content: [{&apos;type&apos;: &apos;tool_call_chunk&apos;, &apos;id&apos;: None, &apos;name&apos;: None, &apos;args&apos;: &apos;San&apos;, &apos;index&apos;: 0}]


node: model
content: [{&apos;type&apos;: &apos;tool_call_chunk&apos;, &apos;id&apos;: None, &apos;name&apos;: None, &apos;args&apos;: &apos; Francisco&apos;, &apos;index&apos;: 0}]


node: model
content: [{&apos;type&apos;: &apos;tool_call_chunk&apos;, &apos;id&apos;: None, &apos;name&apos;: None, &apos;args&apos;: &apos;&quot;}&apos;, &apos;index&apos;: 0}]


node: model
content: []


node: tools
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &quot;It&apos;s always sunny in San Francisco!&quot;}]


node: model
content: []


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos;Here&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos;&apos;s&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; what&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; I&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; got&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos;:&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; &quot;&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &quot;It&apos;s&quot;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; always&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; sunny&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; in&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; San&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos; Francisco&apos;}]


node: model
content: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &apos;!&quot;\n\n&apos;}]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) custom&lt;/h3&gt;
&lt;p&gt;自定义流式信息怎么写。若要在工具执行时流式传输更新信息，可使用get_stream_writer。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langgraph.config import get_stream_writer  


def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;
    writer = get_stream_writer()
    # stream any arbitrary data
    writer(f&quot;Looking up data for city: {city}&quot;)
    writer(f&quot;Acquired data for city: {city}&quot;)
    return f&quot;It&apos;s always sunny in {city}!&quot;

agent = create_agent(
    model=&quot;claude-sonnet-4-6&quot;,
    tools=[get_weather],
)

for chunk in agent.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What is the weather in SF?&quot;}]},
    stream_mode=&quot;custom&quot;,
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;custom&quot;:
        print(chunk[&quot;data&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Looking up data for city: San Francisco
Acquired data for city: San Francisco
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(4) 多重模式&lt;/h3&gt;
&lt;p&gt;你可以通过将流模式以列表形式传递来指定多种流模式：stream_mode=[&quot;updates&quot;, &quot;custom&quot;]。
每个流式数据块都是一个包含type、ns和data键的StreamPart字典。使用chunk[&quot;type&quot;]来确定流模式，并通过chunk[&quot;data&quot;]访问有效载荷。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langgraph.config import get_stream_writer


def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;
    writer = get_stream_writer()
    writer(f&quot;Looking up data for city: {city}&quot;)
    writer(f&quot;Acquired data for city: {city}&quot;)
    return f&quot;It&apos;s always sunny in {city}!&quot;

agent = create_agent(
    model=&quot;gpt-5-nano&quot;,
    tools=[get_weather],
)

for chunk in agent.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What is the weather in SF?&quot;}]},
    stream_mode=[&quot;updates&quot;, &quot;custom&quot;],
    version=&quot;v2&quot;,
):
    print(f&quot;stream_mode: {chunk[&apos;type&apos;]}&quot;)
    print(f&quot;content: {chunk[&apos;data&apos;]}&quot;)
    print(&quot;\n&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stream_mode: updates
content: {&apos;model&apos;: {&apos;messages&apos;: [AIMessage(content=&apos;&apos;, response_metadata={&apos;token_usage&apos;: {&apos;completion_tokens&apos;: 280, &apos;prompt_tokens&apos;: 132, &apos;total_tokens&apos;: 412, &apos;completion_tokens_details&apos;: {&apos;accepted_prediction_tokens&apos;: 0, &apos;audio_tokens&apos;: 0, &apos;reasoning_tokens&apos;: 256, &apos;rejected_prediction_tokens&apos;: 0}, &apos;prompt_tokens_details&apos;: {&apos;audio_tokens&apos;: 0, &apos;cached_tokens&apos;: 0}}, &apos;model_provider&apos;: &apos;openai&apos;, &apos;model_name&apos;: &apos;gpt-5-nano-2025-08-07&apos;, &apos;system_fingerprint&apos;: None, &apos;id&apos;: &apos;chatcmpl-C9tlgBzGEbedGYxZ0rTCz5F7OXpL7&apos;, &apos;service_tier&apos;: &apos;default&apos;, &apos;finish_reason&apos;: &apos;tool_calls&apos;, &apos;logprobs&apos;: None}, id=&apos;lc_run--480c07cb-e405-4411-aa7f-0520fddeed66-0&apos;, tool_calls=[{&apos;name&apos;: &apos;get_weather&apos;, &apos;args&apos;: {&apos;city&apos;: &apos;San Francisco&apos;}, &apos;id&apos;: &apos;call_KTNQIftMrl9vgNwEfAJMVu7r&apos;, &apos;type&apos;: &apos;tool_call&apos;}], usage_metadata={&apos;input_tokens&apos;: 132, &apos;output_tokens&apos;: 280, &apos;total_tokens&apos;: 412, &apos;input_token_details&apos;: {&apos;audio&apos;: 0, &apos;cache_read&apos;: 0}, &apos;output_token_details&apos;: {&apos;audio&apos;: 0, &apos;reasoning&apos;: 256}})]}}


stream_mode: custom
content: Looking up data for city: San Francisco


stream_mode: custom
content: Acquired data for city: San Francisco


stream_mode: updates
content: {&apos;tools&apos;: {&apos;messages&apos;: [ToolMessage(content=&quot;It&apos;s always sunny in San Francisco!&quot;, name=&apos;get_weather&apos;, tool_call_id=&apos;call_KTNQIftMrl9vgNwEfAJMVu7r&apos;)]}}


stream_mode: updates
content: {&apos;model&apos;: {&apos;messages&apos;: [AIMessage(content=&apos;San Francisco weather: It&apos;s always sunny in San Francisco!\n\n&apos;, response_metadata={&apos;token_usage&apos;: {&apos;completion_tokens&apos;: 764, &apos;prompt_tokens&apos;: 168, &apos;total_tokens&apos;: 932, &apos;completion_tokens_details&apos;: {&apos;accepted_prediction_tokens&apos;: 0, &apos;audio_tokens&apos;: 0, &apos;reasoning_tokens&apos;: 704, &apos;rejected_prediction_tokens&apos;: 0}, &apos;prompt_tokens_details&apos;: {&apos;audio_tokens&apos;: 0, &apos;cached_tokens&apos;: 0}}, &apos;model_provider&apos;: &apos;openai&apos;, &apos;model_name&apos;: &apos;gpt-5-nano-2025-08-07&apos;, &apos;system_fingerprint&apos;: None, &apos;id&apos;: &apos;chatcmpl-C9tljDFVki1e1haCyikBptAuXuHYG&apos;, &apos;service_tier&apos;: &apos;default&apos;, &apos;finish_reason&apos;: &apos;stop&apos;, &apos;logprobs&apos;: None}, id=&apos;lc_run--acbc740a-18fe-4a14-8619-da92a0d0ee90-0&apos;, usage_metadata={&apos;input_tokens&apos;: 168, &apos;output_tokens&apos;: 764, &apos;total_tokens&apos;: 932, &apos;input_token_details&apos;: {&apos;audio&apos;: 0, &apos;cache_read&apos;: 0}, &apos;output_token_details&apos;: {&apos;audio&apos;: 0, &apos;reasoning&apos;: 704}})]}}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 常见使用场景&lt;/h2&gt;
&lt;h3&gt;(1) 流式传输思考/推理token&lt;/h3&gt;
&lt;p&gt;可以通过筛选标准内容块中type为&quot;reasoning&quot;的内容，实时流式传输这些生成的思考 / 推理令牌。&lt;/p&gt;
&lt;p&gt;若要流式传输智能体的思考令牌，可使用stream_mode=&quot;messages&quot;并筛选推理内容块。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.messages import AIMessageChunk
from langchain_anthropic import ChatAnthropic
from langchain_core.runnables import Runnable


def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;
    return f&quot;It&apos;s always sunny in {city}!&quot;


model = ChatAnthropic(
    model_name=&quot;claude-sonnet-4-6&quot;,
    timeout=None,
    stop=None,
    thinking={&quot;type&quot;: &quot;enabled&quot;, &quot;budget_tokens&quot;: 5000},
)
agent: Runnable = create_agent(
    model=model,
    tools=[get_weather],
)

for token, metadata in agent.stream(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What is the weather in SF?&quot;}]},
    stream_mode=&quot;messages&quot;,
):
    if not isinstance(token, AIMessageChunk):
        continue
    reasoning = [b for b in token.content_blocks if b[&quot;type&quot;] == &quot;reasoning&quot;]
    text = [b for b in token.content_blocks if b[&quot;type&quot;] == &quot;text&quot;]
    if reasoning:
        print(f&quot;[thinking] {reasoning[0][&apos;reasoning&apos;]}&quot;, end=&quot;&quot;)
    if text:
        print(text[0][&quot;text&quot;], end=&quot;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出会类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[thinking] The user is asking about the weather in San Francisco. I have a tool
[thinking]  available to get this information. Let me call the get_weather tool
[thinking]  with &quot;San Francisco&quot; as the city parameter.
The weather in San Francisco is: It&apos;s always sunny in San Francisco!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;无论模型提供商是谁，其工作原理均保持一致 ——LangChain 会通过content_blocks属性，将各提供商专属的格式（Anthropic 的thinking模块、OpenAI 的reasoning摘要等）统一规范化为标准的&quot;reasoning&quot;内容块类型。&lt;/p&gt;
&lt;h3&gt;(2) 流式工具调用&lt;/h3&gt;
&lt;p&gt;可能需要同时流式传输以下两类内容：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工具调用生成过程中的部分 JSON 数据&lt;/li&gt;
&lt;li&gt;执行完毕且已解析的完整工具调用结果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;指定stream_mode=&quot;messages&quot;将流式传输智能体中所有大语言模型调用生成的增量消息片段&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;若这些消息在状态中被追踪（如create_agent的模型节点中），可使用stream_mode=[&quot;messages&quot;, &quot;updates&quot;]，通过状态更新获取完整消息（如下方示例所示）。&lt;/li&gt;
&lt;li&gt;若这些消息未在状态中被追踪，则可使用自定义更新，或在流式循环过程中聚合消息片段（下一节）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;代码示例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Any

from langchain.agents import create_agent
from langchain.messages import AIMessage, AIMessageChunk, AnyMessage, ToolMessage


def get_weather(city: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather for a given city.&quot;&quot;&quot;

    return f&quot;It&apos;s always sunny in {city}!&quot;


agent = create_agent(&quot;openai:gpt-5.2&quot;, tools=[get_weather])


def _render_message_chunk(token: AIMessageChunk) -&amp;gt; None:
    if token.text:
        print(token.text, end=&quot;|&quot;)
    if token.tool_call_chunks:
        print(token.tool_call_chunks)
    # N.B. all content is available through token.content_blocks


def _render_completed_message(message: AnyMessage) -&amp;gt; None:
    if isinstance(message, AIMessage) and message.tool_calls:
        print(f&quot;Tool calls: {message.tool_calls}&quot;)
    if isinstance(message, ToolMessage):
        print(f&quot;Tool response: {message.content_blocks}&quot;)


input_message = {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What is the weather in Boston?&quot;}
for chunk in agent.stream(
    {&quot;messages&quot;: [input_message]},
    stream_mode=[&quot;messages&quot;, &quot;updates&quot;],
    version=&quot;v2&quot;,
):
    if chunk[&quot;type&quot;] == &quot;messages&quot;:
        token, metadata = chunk[&quot;data&quot;]
        if isinstance(token, AIMessageChunk):
            _render_message_chunk(token)
    elif chunk[&quot;type&quot;] == &quot;updates&quot;:
        for source, update in chunk[&quot;data&quot;].items():
            if source in (&quot;model&quot;, &quot;tools&quot;):  # `source` captures node name
                _render_completed_message(update[&quot;messages&quot;][-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[{&apos;name&apos;: &apos;get_weather&apos;, &apos;args&apos;: &apos;&apos;, &apos;id&apos;: &apos;call_D3Orjr89KgsLTZ9hTzYv7Hpf&apos;, &apos;index&apos;: 0, &apos;type&apos;: &apos;tool_call_chunk&apos;}]
[{&apos;name&apos;: None, &apos;args&apos;: &apos;{&quot;&apos;, &apos;id&apos;: None, &apos;index&apos;: 0, &apos;type&apos;: &apos;tool_call_chunk&apos;}]
[{&apos;name&apos;: None, &apos;args&apos;: &apos;city&apos;, &apos;id&apos;: None, &apos;index&apos;: 0, &apos;type&apos;: &apos;tool_call_chunk&apos;}]
[{&apos;name&apos;: None, &apos;args&apos;: &apos;&quot;:&quot;&apos;, &apos;id&apos;: None, &apos;index&apos;: 0, &apos;type&apos;: &apos;tool_call_chunk&apos;}]
[{&apos;name&apos;: None, &apos;args&apos;: &apos;Boston&apos;, &apos;id&apos;: None, &apos;index&apos;: 0, &apos;type&apos;: &apos;tool_call_chunk&apos;}]
[{&apos;name&apos;: None, &apos;args&apos;: &apos;&quot;}&apos;, &apos;id&apos;: None, &apos;index&apos;: 0, &apos;type&apos;: &apos;tool_call_chunk&apos;}]
Tool calls: [{&apos;name&apos;: &apos;get_weather&apos;, &apos;args&apos;: {&apos;city&apos;: &apos;Boston&apos;}, &apos;id&apos;: &apos;call_D3Orjr89KgsLTZ9hTzYv7Hpf&apos;, &apos;type&apos;: &apos;tool_call&apos;}]
Tool response: [{&apos;type&apos;: &apos;text&apos;, &apos;text&apos;: &quot;It&apos;s always sunny in Boston!&quot;}]
The| weather| in| Boston| is| **|sun|ny|**|.|
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(3) 访问已完成信息&lt;/h3&gt;
&lt;h3&gt;(4) Steaming with human-in-the-loop&lt;/h3&gt;
&lt;h3&gt;(5) Streaming from sub-agents&lt;/h3&gt;
&lt;h2&gt;4. 禁用streaming&lt;/h2&gt;
&lt;p&gt;有些时候需要禁用单个模型token的流式输出，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用多智能体系统时，控制哪些智能体进行输出流式传输&lt;/li&gt;
&lt;li&gt;将支持流式传输的模型与不支持该功能的模型混合使用&lt;/li&gt;
&lt;li&gt;部署至LangSmith平台，且希望阻止特定模型的输出流式传输至客户端&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样可以直接在模型构建的时候传入&lt;code&gt;streaming=False&lt;/code&gt;完成。&lt;/p&gt;
&lt;h2&gt;5. v2流式输出格式&lt;/h2&gt;
&lt;p&gt;其实你可以注意到，前面已经用到了&lt;code&gt;version = &quot;v2&quot;&lt;/code&gt;了，其实v2和v1（默认）的区别就在于，前者有统一的输出格式，每个StreamPart都是包含type、ns、data作为key的输出，而v1则会传回类似(mode,data)的元组，需要你手动unpack。&lt;/p&gt;
&lt;p&gt;此外，v2 格式还改进了invoke()方法 —— 它会返回一个包含.value和.interrupts属性的GraphOutput对象，将状态与中断元数据清晰地分离开来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;result = agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Hello&quot;}]},
    version=&quot;v2&quot;,
)
print(result.value)       # state (dict, Pydantic model, or dataclass)
print(result.interrupts)  # tuple of Interrupt objects (empty if none)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>RAG 索引优化：上下文拓展与结构化索引</title><link>https://owen571.top/posts/study/rag/08-rag-%E7%B4%A2%E5%BC%95%E4%BC%98%E5%8C%96-%E4%B8%8A%E4%B8%8B%E6%96%87%E6%8B%93%E5%B1%95%E4%B8%8E%E7%BB%93%E6%9E%84%E5%8C%96%E7%B4%A2%E5%BC%95/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/08-rag-%E7%B4%A2%E5%BC%95%E4%BC%98%E5%8C%96-%E4%B8%8A%E4%B8%8B%E6%96%87%E6%8B%93%E5%B1%95%E4%B8%8E%E7%BB%93%E6%9E%84%E5%8C%96%E7%B4%A2%E5%BC%95/</guid><description>从 LlamaIndex 提炼出两个很有价值的思路：检索粒度和生成粒度不必相同，以及知识库变大后要学会先过滤、再检索。</description><pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇承接前面的 Naive-RAG 实战。目标不是切换框架，而是把 LlamaIndex 在索引层提供的两个思路吸收下来，再映射回自己当前的 &lt;code&gt;LangChain + Milvus + FastAPI&lt;/code&gt; 主线。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 索引优化&lt;/h1&gt;
&lt;h2&gt;1. LlamaIndex&lt;/h2&gt;
&lt;p&gt;这一章开始接触 LlamaIndex。它和 LangChain 的定位不完全一样，LangChain 更像一个通用的 LLM 应用框架，链、工具、Agent、RAG 都能做；而 LlamaIndex 更聚焦在“怎么把数据接进 LLM”，也就是文档、索引、检索、查询这一套抽象会更清楚一些。&lt;/p&gt;
&lt;p&gt;不过，我当前的主线仍然是 &lt;code&gt;LangChain + Milvus + FastAPI&lt;/code&gt;，所以这里没必要切到另一个框架去重做一遍 demo。更合适的做法是，把 LlamaIndex 里面对 RAG 有启发的索引优化思想吸收下来，看懂它到底在解决什么问题，之后再映射回我自己的项目。&lt;/p&gt;
&lt;p&gt;这一章主要记两种索引优化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;上下文拓展&lt;/li&gt;
&lt;li&gt;结构化索引&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 上下文拓展&lt;/h2&gt;
&lt;p&gt;RAG 里一直有一个很经典的矛盾：如果 chunk 切得很小，检索时通常更精确，因为语义更集中，更容易命中真正相关的那一句或那一小段；但与此同时，小 chunk 给 LLM 的上下文又太少，最后回答可能不完整，甚至语义不连贯。反过来，如果 chunk 一开始就切得很大，那上下文当然更完整，但检索时又容易混入很多无关信息，召回虽然“看起来相关”，其实噪音会明显增加。&lt;/p&gt;
&lt;p&gt;LlamaIndex 针对这个问题给出的一个很直观的思路就是句子窗口检索（Sentence Window Retrieval）。它不是简单地说“应该切大一点”或者“应该切小一点”，而是把检索阶段和生成阶段分开看：检索时，仍然用非常小的单位去找；但在交给 LLM 生成答案之前，再把它恢复成一个更大的上下文窗口。&lt;/p&gt;
&lt;p&gt;它的核心代码并不复杂：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. 加载文档
documents = SimpleDirectoryReader(
    input_files=[&quot;../../data/C3/pdf/IPCC_AR6_WGII_Chapter03.pdf&quot;]
).load_data()

# 2. 创建句子窗口索引
node_parser = SentenceWindowNodeParser.from_defaults(
    window_size=3,
    window_metadata_key=&quot;window&quot;,
    original_text_metadata_key=&quot;original_text&quot;,
)
sentence_nodes = node_parser.get_nodes_from_documents(documents)
sentence_index = VectorStoreIndex(sentence_nodes)

# 3. 构建查询引擎
sentence_query_engine = sentence_index.as_query_engine(
    similarity_top_k=2,
    node_postprocessors=[
        MetadataReplacementPostProcessor(target_metadata_key=&quot;window&quot;)
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;乍一看其实也没有什么魔法，核心就两步：&lt;/p&gt;
&lt;p&gt;第一步，&lt;code&gt;SentenceWindowNodeParser&lt;/code&gt; 会把文档切成一个个句子。注意这里不是普通的固定长度切块，而是按句子切。每个句子都会变成一个节点，同时它还会额外保存一段“窗口”信息，也就是这个句子前后若干句组成的上下文。&lt;/p&gt;
&lt;p&gt;第二步，在真正查询时，检索器先检索的仍然是这些小句子节点。也就是说，相似度匹配依然发生在“小而精”的粒度上。等命中之后，再通过 &lt;code&gt;MetadataReplacementPostProcessor&lt;/code&gt; 把节点里原本那一句话，替换成之前存在 metadata 里的整段窗口文本。这样最后送给 LLM 的就不再是孤立的一句话，而是带有前后文的一小段内容。&lt;/p&gt;
&lt;p&gt;所以这个方法的本质可以直接记成一句话：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;检索时用小块保证精度，生成时用大块补足上下文。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它解决的不是“向量模型不够强”，而是 chunk 粒度设计的矛盾。&lt;/p&gt;
&lt;p&gt;LlamaIndex 底层怎么做这件事，其实想清楚也不难。它先把文档拆成句子列表，然后遍历每个句子，对第 &lt;code&gt;i&lt;/code&gt; 个句子来说，去取前后若干句，把它们拼起来存进 metadata 里。注意这里很关键的一点是：真正参与 embedding 的，还是当前句子本身；窗口信息只是被当成附加元数据，供后处理阶段替换使用。这样才能保证检索时保持高精度，而不是一开始就把大窗口拿去做 embedding。&lt;/p&gt;
&lt;p&gt;这一点对我很有启发，因为我现在自己的 Naive-RAG 做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先按 markdown 标题切&lt;/li&gt;
&lt;li&gt;再对过长 chunk 递归切分&lt;/li&gt;
&lt;li&gt;检索到什么 chunk，就直接把什么 chunk 交给 LLM&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个方案能跑，而且已经能做出一个端到端 demo。但它的问题也很明显：如果后面遇到那种“某一句特别关键，但单句上下文不足”的情况，我就很容易在两个坏选项之间摇摆：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把 chunk 切得更小，检索更准，但回答更碎&lt;/li&gt;
&lt;li&gt;把 chunk 切得更大，上下文更多，但噪音上来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;句子窗口检索给的启发其实非常直接：&lt;strong&gt;检索粒度和生成粒度，不一定必须相同。&lt;/strong&gt;&lt;br /&gt;
这并不是 LlamaIndex 专属能力，换到我现在的 LangChain + Milvus 项目里也完全能借鉴。比如以后我可以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用更小粒度的句子/短段落建索引&lt;/li&gt;
&lt;li&gt;在 metadata 里保存所属段落或者前后句窗口&lt;/li&gt;
&lt;li&gt;检索时命中小块&lt;/li&gt;
&lt;li&gt;返回给 LLM 时再替换成更大的文本窗口&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这一节真正值得记住的，不是某个类名，而是这个思路本身。&lt;/p&gt;
&lt;h2&gt;3. 结构化索引&lt;/h2&gt;
&lt;p&gt;前面的上下文拓展，主要解决的是 chunk 粒度矛盾；而结构化索引解决的是另一个问题：&lt;strong&gt;知识库大了以后，不能总是对全库做无差别检索。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果知识库里只有几篇文档，那对所有 chunk 直接做 top-k 向量搜索问题不大。但当文档库规模变大，例如几百份 PDF、多个表格、多个年份、多个专题，很多问题其实从一开始就只和一小部分数据有关。这个时候，如果还是在全库里盲搜，不仅效率差，还很容易被无关 chunk 干扰。&lt;/p&gt;
&lt;p&gt;这时候就要引入结构化索引。它的本质不是一种新向量模型，而是：&lt;strong&gt;除了文本和向量，还给每个 chunk 附加结构化元数据，用这些元数据先做过滤或路由。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这些元数据可以很简单，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文件名&lt;/li&gt;
&lt;li&gt;文档类型&lt;/li&gt;
&lt;li&gt;日期&lt;/li&gt;
&lt;li&gt;作者&lt;/li&gt;
&lt;li&gt;一级、二级、三级标题&lt;/li&gt;
&lt;li&gt;自定义标签&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后在检索时走两步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先根据 metadata 把搜索范围缩小&lt;/li&gt;
&lt;li&gt;再在这个缩小后的候选集合里做向量检索&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种“先过滤，再搜索”的思路，在数据规模变大后会非常有用。&lt;/p&gt;
&lt;p&gt;这里其实和我当前项目已经能直接对应上了。因为在第二章我就用了 &lt;code&gt;MarkdownHeaderTextSplitter&lt;/code&gt;，它会自动把 &lt;code&gt;h1 / h2 / h3&lt;/code&gt; 写进每个 chunk 的 metadata 里。后面入 Milvus 的时候，我也把这些字段一起存进去了。所以严格来说，我现在的系统虽然还只是 Naive-RAG，但已经有了结构化索引的雏形。&lt;/p&gt;
&lt;p&gt;换句话说：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;基于文档结构切块，并保留标题 metadata，本身就是结构化索引的一部分。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这意味着后面我完全可以继续往前走，而不是另起一套：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只在某一篇文档里搜索&lt;/li&gt;
&lt;li&gt;只在某个一级标题下搜索&lt;/li&gt;
&lt;li&gt;只在某个章节范围里搜索&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些都属于结构化索引的直接应用。&lt;/p&gt;
&lt;p&gt;LlamaIndex 在这一块常见会讲到两种比较典型的实现方式。&lt;/p&gt;
&lt;p&gt;第一种比较直白，就是 metadata filtering。&lt;br /&gt;
也就是先根据结构化信息筛一遍，再做向量搜索。比如问题是“请总结 2023 年第二季度财报里关于 AI 的论述”，那就没必要在整个知识库乱搜，而是先限定在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文档类型 = 财报&lt;/li&gt;
&lt;li&gt;年份 = 2023&lt;/li&gt;
&lt;li&gt;季度 = Q2&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后再做相似度搜索。&lt;/p&gt;
&lt;p&gt;第二种更进一步，是“先路由，再进入目标数据源做检索”，也就是递归检索或者分层检索的思路。LlamaIndex 经常举的例子是多工作表 Excel：每个 sheet 单独是一个数据源，先用摘要节点判断问题属于哪个表，再进入那个表里继续查询。&lt;/p&gt;
&lt;p&gt;这个例子的代码挺长，但真正需要记住的不是 &lt;code&gt;RecursiveRetriever&lt;/code&gt; 或者 &lt;code&gt;PandasQueryEngine&lt;/code&gt; 的具体调用，而是它背后的逻辑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先为每个子数据源准备摘要&lt;/li&gt;
&lt;li&gt;用摘要做第一层路由&lt;/li&gt;
&lt;li&gt;命中后再进入目标数据源做第二层查询&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，这种方法的核心不是“递归”这个形式，而是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;先决定去哪搜，再决定怎么搜。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个思路如果放回我自己的项目里，其实也很容易理解。比如以后如果我不只是有一套 RL 笔记，而是有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;强化学习&lt;/li&gt;
&lt;li&gt;RAG&lt;/li&gt;
&lt;li&gt;LangChain&lt;/li&gt;
&lt;li&gt;系统设计&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;几套完全不同的知识库，那我就没必要每次都把所有内容放在一起无差别检索。更合理的做法是，先判断这个问题大概属于哪个知识域，再进入对应知识域检索。这就是结构化索引更进一步的价值。&lt;/p&gt;
&lt;p&gt;另外，LlamaIndex 在多表格例子里还用了 &lt;code&gt;PandasQueryEngine&lt;/code&gt; 这类能让 LLM 生成 Pandas 代码并执行的工具。这个思路很强，但它也带来明显的安全问题，因为本质上已经接近“让模型生成代码然后执行”。所以这里我更应该记住的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;结构化索引和路由是有价值的&lt;/li&gt;
&lt;li&gt;但具体实现方式要考虑安全性&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于我现在的主线来说，最实际的落点还是前面那条：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;优先把 metadata 用起来，先做过滤，再做向量检索。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;4. 关于框架&lt;/h2&gt;
&lt;p&gt;框架是加速开发的强大工具，是帮助我们快速跨越技术鸿沟的“桥梁”。但任何桥梁都有其设计边界和局限性。我们的目标不是成为一个熟练的“过桥者”，而是成为一个懂得如何设计和建造桥梁的“工程师”。&lt;/p&gt;
&lt;p&gt;如果希望深入某个框架的细节，官方文档永远是最好的选择。但是现在的学习，是为了建立起关于RAG的坚实知识体系，这样在切换工具的时候也能游刃有余。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 核心组件 06：Structured Output</title><link>https://owen571.top/posts/study/langchain/08-structured-output/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/08-structured-output/</guid><description>当你不想只拿一段自然语言，而是想拿稳定可解析的数据结构时，应该如何在 LangChain 中设计响应格式。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;结构化输出我放在 Agents 前面，是因为它本质上是“让模型结果进入程序逻辑”的桥。等这个概念清楚了，再去看 Agent 里的 &lt;code&gt;response_format&lt;/code&gt; 就不会突兀。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;结构化输出使得智能体能够以特定、可预测的格式返回数据。你无需解析自然语言响应，而是可以直接获取以 JSON 对象、Pydantic 模型或数据类形式呈现的结构化数据，供应用程序直接使用。&lt;/p&gt;
&lt;p&gt;LangChain 的create_agent可自动处理结构化输出。用户设置所需的结构化输出模式，当模型生成结构化数据时，该数据会被捕获、验证，并以&lt;code&gt;structured_response&lt;/code&gt;为键名返回至智能体状态中。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def create_agent(
    ...
    response_format: Union[
        ToolStrategy[StructuredResponseT],
        ProviderStrategy[StructuredResponseT],
        type[StructuredResponseT],
        None,
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;1. 响应格式&lt;/h2&gt;
&lt;p&gt;使用response_format控制智能体返回结构化数据的方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ToolStrategy[StructuredResponseT]：通过工具调用实现结构化输出&lt;/li&gt;
&lt;li&gt;ProviderStrategy[StructuredResponseT]：采用服务提供商原生结构化输出&lt;/li&gt;
&lt;li&gt;type[StructuredResponseT]：架构类型 —— 根据模型能力自动选择最优策略&lt;/li&gt;
&lt;li&gt;None：未明确请求结构化输出&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当直接提供架构类型时，LangChain 会自动选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ProviderStrategy（若所选模型及服务提供商支持原生结构化输出，例如 OpenAI、Anthropic (Claude)或xAI (Grok)）。&lt;/li&gt;
&lt;li&gt;ToolStrategy（适用于其他所有模型）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. Provider strategy&lt;/h2&gt;
&lt;p&gt;部分模型提供商通过其 API 原生支持结构化输出（例如 OpenAI、xAI（Grok）、Gemini、Anthropic（Claude））。在可用的情况下，这是最可靠的方法。&lt;/p&gt;
&lt;p&gt;要使用此策略，请配置一个ProviderStrategy：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ProviderStrategy(Generic[SchemaT]):
    schema: type[SchemaT]
    strict: bool | None = None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;定义结构化输出格式的模式。支持：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pydantic 模型：带有字段验证的BaseModel子类，返回经过验证的 Pydantic 实例。&lt;/li&gt;
&lt;li&gt;数据类：带有类型注解的 Python 数据类，返回字典。&lt;/li&gt;
&lt;li&gt;类型字典：类型化字典类，返回字典。&lt;/li&gt;
&lt;li&gt;JSON 模式：包含 JSON 模式规范的字典，返回字典。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面提供这四种情况的代码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel, Field
from langchain.agents import create_agent


class ContactInfo(BaseModel):
    &quot;&quot;&quot;Contact information for a person.&quot;&quot;&quot;
    name: str = Field(description=&quot;The name of the person&quot;)
    email: str = Field(description=&quot;The email address of the person&quot;)
    phone: str = Field(description=&quot;The phone number of the person&quot;)

agent = create_agent(
    model=&quot;gpt-5&quot;,
    response_format=ContactInfo  # Auto-selects ProviderStrategy
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Extract contact info from: John Doe, john@example.com, (555) 123-4567&quot;}]
})

print(result[&quot;structured_response&quot;])
# ContactInfo(name=&apos;John Doe&apos;, email=&apos;john@example.com&apos;, phone=&apos;(555) 123-4567&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass
from langchain.agents import create_agent


@dataclass
class ContactInfo:
    &quot;&quot;&quot;Contact information for a person.&quot;&quot;&quot;
    name: str # The name of the person
    email: str # The email address of the person
    phone: str # The phone number of the person

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ContactInfo  # Auto-selects ProviderStrategy
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Extract contact info from: John Doe, john@example.com, (555) 123-4567&quot;}]
})

result[&quot;structured_response&quot;]
# {&apos;name&apos;: &apos;John Doe&apos;, &apos;email&apos;: &apos;john@example.com&apos;, &apos;phone&apos;: &apos;(555) 123-4567&apos;}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from typing_extensions import TypedDict
from langchain.agents import create_agent


class ContactInfo(TypedDict):
    &quot;&quot;&quot;Contact information for a person.&quot;&quot;&quot;
    name: str # The name of the person
    email: str # The email address of the person
    phone: str # The phone number of the person

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ContactInfo  # Auto-selects ProviderStrategy
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Extract contact info from: John Doe, john@example.com, (555) 123-4567&quot;}]
})

result[&quot;structured_response&quot;]
# {&apos;name&apos;: &apos;John Doe&apos;, &apos;email&apos;: &apos;john@example.com&apos;, &apos;phone&apos;: &apos;(555) 123-4567&apos;}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent


contact_info_schema = {
    &quot;type&quot;: &quot;object&quot;,
    &quot;description&quot;: &quot;Contact information for a person.&quot;,
    &quot;properties&quot;: {
        &quot;name&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;The name of the person&quot;},
        &quot;email&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;The email address of the person&quot;},
        &quot;phone&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;The phone number of the person&quot;}
    },
    &quot;required&quot;: [&quot;name&quot;, &quot;email&quot;, &quot;phone&quot;]
}

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ProviderStrategy(contact_info_schema)
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Extract contact info from: John Doe, john@example.com, (555) 123-4567&quot;}]
})

result[&quot;structured_response&quot;]
# {&apos;name&apos;: &apos;John Doe&apos;, &apos;email&apos;: &apos;john@example.com&apos;, &apos;phone&apos;: &apos;(555) 123-4567&apos;}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. Tool calling strategy&lt;/h2&gt;
&lt;p&gt;对于不支持原生结构化输出的模型，LangChain 会通过工具调用来实现相同的效果。该方法适用于所有支持工具调用的模型（大多数现代模型）。&lt;/p&gt;
&lt;p&gt;要使用此策略，请配置ToolStrategy：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ToolStrategy(Generic[SchemaT]):
    schema: type[SchemaT]
    tool_message_content: str | None
    handle_errors: Union[
        bool,
        str,
        type[Exception],
        tuple[type[Exception], ...],
        Callable[[Exception], str],
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同样，这里的schema模版支持以下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pydantic 模型：BaseModel 子类，具备字段校验功能，返回校验后的 Pydantic 实例。&lt;/li&gt;
&lt;li&gt;数据类：带有类型注解的 Python 数据类，返回字典。&lt;/li&gt;
&lt;li&gt;类型字典：类型化字典类，返回字典。&lt;/li&gt;
&lt;li&gt;JSON 模式：符合 JSON 模式规范的字典，返回字典。&lt;/li&gt;
&lt;li&gt;联合类型：多种模式选项，模型会根据上下文选择最合适的模式。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里分别提供五种情况的示例代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel, Field
from typing import Literal
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy


class ProductReview(BaseModel):
    &quot;&quot;&quot;Analysis of a product review.&quot;&quot;&quot;
    rating: int | None = Field(description=&quot;The rating of the product&quot;, ge=1, le=5)
    sentiment: Literal[&quot;positive&quot;, &quot;negative&quot;] = Field(description=&quot;The sentiment of the review&quot;)
    key_points: list[str] = Field(description=&quot;The key points of the review. Lowercase, 1-3 words each.&quot;)

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ToolStrategy(ProductReview)
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Analyze this review: &apos;Great product: 5 out of 5 stars. Fast shipping, but expensive&apos;&quot;}]
})
result[&quot;structured_response&quot;]
# ProductReview(rating=5, sentiment=&apos;positive&apos;, key_points=[&apos;fast shipping&apos;, &apos;expensive&apos;])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass
from typing import Literal
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy


@dataclass
class ProductReview:
    &quot;&quot;&quot;Analysis of a product review.&quot;&quot;&quot;
    rating: int | None  # The rating of the product (1-5)
    sentiment: Literal[&quot;positive&quot;, &quot;negative&quot;]  # The sentiment of the review
    key_points: list[str]  # The key points of the review

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ToolStrategy(ProductReview)
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Analyze this review: &apos;Great product: 5 out of 5 stars. Fast shipping, but expensive&apos;&quot;}]
})
result[&quot;structured_response&quot;]
# {&apos;rating&apos;: 5, &apos;sentiment&apos;: &apos;positive&apos;, &apos;key_points&apos;: [&apos;fast shipping&apos;, &apos;expensive&apos;]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from typing import Literal
from typing_extensions import TypedDict
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy


class ProductReview(TypedDict):
    &quot;&quot;&quot;Analysis of a product review.&quot;&quot;&quot;
    rating: int | None  # The rating of the product (1-5)
    sentiment: Literal[&quot;positive&quot;, &quot;negative&quot;]  # The sentiment of the review
    key_points: list[str]  # The key points of the review

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ToolStrategy(ProductReview)
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Analyze this review: &apos;Great product: 5 out of 5 stars. Fast shipping, but expensive&apos;&quot;}]
})
result[&quot;structured_response&quot;]
# {&apos;rating&apos;: 5, &apos;sentiment&apos;: &apos;positive&apos;, &apos;key_points&apos;: [&apos;fast shipping&apos;, &apos;expensive&apos;]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy


product_review_schema = {
    &quot;type&quot;: &quot;object&quot;,
    &quot;description&quot;: &quot;Analysis of a product review.&quot;,
    &quot;properties&quot;: {
        &quot;rating&quot;: {
            &quot;type&quot;: [&quot;integer&quot;, &quot;null&quot;],
            &quot;description&quot;: &quot;The rating of the product (1-5)&quot;,
            &quot;minimum&quot;: 1,
            &quot;maximum&quot;: 5
        },
        &quot;sentiment&quot;: {
            &quot;type&quot;: &quot;string&quot;,
            &quot;enum&quot;: [&quot;positive&quot;, &quot;negative&quot;],
            &quot;description&quot;: &quot;The sentiment of the review&quot;
        },
        &quot;key_points&quot;: {
            &quot;type&quot;: &quot;array&quot;,
            &quot;items&quot;: {&quot;type&quot;: &quot;string&quot;},
            &quot;description&quot;: &quot;The key points of the review&quot;
        }
    },
    &quot;required&quot;: [&quot;sentiment&quot;, &quot;key_points&quot;]
}

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ToolStrategy(product_review_schema)
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Analyze this review: &apos;Great product: 5 out of 5 stars. Fast shipping, but expensive&apos;&quot;}]
})
result[&quot;structured_response&quot;]
# {&apos;rating&apos;: 5, &apos;sentiment&apos;: &apos;positive&apos;, &apos;key_points&apos;: [&apos;fast shipping&apos;, &apos;expensive&apos;]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy


product_review_schema = {
    &quot;type&quot;: &quot;object&quot;,
    &quot;description&quot;: &quot;Analysis of a product review.&quot;,
    &quot;properties&quot;: {
        &quot;rating&quot;: {
            &quot;type&quot;: [&quot;integer&quot;, &quot;null&quot;],
            &quot;description&quot;: &quot;The rating of the product (1-5)&quot;,
            &quot;minimum&quot;: 1,
            &quot;maximum&quot;: 5
        },
        &quot;sentiment&quot;: {
            &quot;type&quot;: &quot;string&quot;,
            &quot;enum&quot;: [&quot;positive&quot;, &quot;negative&quot;],
            &quot;description&quot;: &quot;The sentiment of the review&quot;
        },
        &quot;key_points&quot;: {
            &quot;type&quot;: &quot;array&quot;,
            &quot;items&quot;: {&quot;type&quot;: &quot;string&quot;},
            &quot;description&quot;: &quot;The key points of the review&quot;
        }
    },
    &quot;required&quot;: [&quot;sentiment&quot;, &quot;key_points&quot;]
}

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=tools,
    response_format=ToolStrategy(product_review_schema)
)

result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Analyze this review: &apos;Great product: 5 out of 5 stars. Fast shipping, but expensive&apos;&quot;}]
})
result[&quot;structured_response&quot;]
# {&apos;rating&apos;: 5, &apos;sentiment&apos;: &apos;positive&apos;, &apos;key_points&apos;: [&apos;fast shipping&apos;, &apos;expensive&apos;]}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. Custom tool message content&lt;/h2&gt;
&lt;p&gt;tool_message_content 参数允许你自定义生成结构化输出时，显示在对话历史中的消息内容。效果如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import BaseModel, Field
from typing import Literal
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy


class MeetingAction(BaseModel):
    &quot;&quot;&quot;Action items extracted from a meeting transcript.&quot;&quot;&quot;
    task: str = Field(description=&quot;The specific task to be completed&quot;)
    assignee: str = Field(description=&quot;Person responsible for the task&quot;)
    priority: Literal[&quot;low&quot;, &quot;medium&quot;, &quot;high&quot;] = Field(description=&quot;Priority level&quot;)

agent = create_agent(
    model=&quot;gpt-5&quot;,
    tools=[],
    response_format=ToolStrategy(
        schema=MeetingAction,
        tool_message_content=&quot;Action item captured and added to meeting notes!&quot;
    )
)

agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;From our meeting: Sarah needs to update the project timeline as soon as possible&quot;}]
})
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;================================ Human Message =================================

From our meeting: Sarah needs to update the project timeline as soon as possible
================================== Ai Message ==================================
Tool Calls:
  MeetingAction (call_1)
 Call ID: call_1
  Args:
    task: Update the project timeline
    assignee: Sarah
    priority: high
================================= Tool Message =================================
Name: MeetingAction

Action item captured and added to meeting notes!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果没有tool_message_content，最终的ToolMessage会像这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;================================= Tool Message =================================
Name: MeetingAction

Returning structured response: {&apos;task&apos;: &apos;update the project timeline&apos;, &apos;assignee&apos;: &apos;Sarah&apos;, &apos;priority&apos;: &apos;high&apos;}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 错误处理&lt;/h2&gt;
&lt;p&gt;先skip&lt;/p&gt;
</content:encoded></item><item><title>RAG 混合检索：稀疏、密集与 Milvus 实现</title><link>https://owen571.top/posts/study/rag/09-rag-%E6%B7%B7%E5%90%88%E6%A3%80%E7%B4%A2-%E7%A8%80%E7%96%8F%E5%AF%86%E9%9B%86%E4%B8%8E-milvus-%E5%AE%9E%E7%8E%B0/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/09-rag-%E6%B7%B7%E5%90%88%E6%A3%80%E7%B4%A2-%E7%A8%80%E7%96%8F%E5%AF%86%E9%9B%86%E4%B8%8E-milvus-%E5%AE%9E%E7%8E%B0/</guid><description>把混合检索拆成三层来理解：稀疏向量在做什么、密集向量在补什么，以及 Milvus 里怎样真正把两者并行召回并融合。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇开始正式进入“检索优化”阶段。前面的重点是先把索引和基础检索链路搭起来，这一篇则开始回答一个更现实的问题：只靠单路 dense 检索，什么时候会不够。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 检索优化&lt;/h1&gt;
&lt;h1&gt;一、混合检索&lt;/h1&gt;
&lt;p&gt;混合检索（Hybrid Search）是一种结合了 稀疏向量（Sparse Vectors） 和 密集向量（Dense Vectors） 优势的先进搜索技术。旨在同时利用稀疏向量的关键词精确匹配能力和密集向量的语义理解能力，以克服单一向量检索的局限性，从而在各种搜索场景下提供更准确、更鲁棒的检索结果。&lt;/p&gt;
&lt;h2&gt;1. 稀疏向量&lt;/h2&gt;
&lt;p&gt;稀疏向量，也常被称为“词法向量”，是基于词频统计的传统信息检索方法的数学表示。它通常是一个维度极高（与词汇表大小相当）但绝大多数元素为零的向量。经典方法包括one-hot、Bag of words、TF、TF-IDF、BM25等。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One-hot：先建一个词表，每个词对应固定位置，出现为1，不出现为2&lt;/li&gt;
&lt;li&gt;Bag of Words：把一段文本表示成“词出现次数”的向量，比one-hot更进一步，能表示词频&lt;/li&gt;
&lt;li&gt;TF（Term Frequency）：某个词在当前文档里出现得有多频繁。&lt;/li&gt;
&lt;li&gt;TF-IDF（Term Frequency * Inverse Document Frequency）：在TF的基础又加了一层全局分布度，思想是一个词在当前文档里出现很多次，说明它对这篇文档重要。相反如果它在所有文档里都很常见，那它区分度不高，应该降权。&lt;/li&gt;
&lt;li&gt;BM25：可以看成是对TF-IDF的进一步改进，是检索算法中非常经典的排序函数，它综合考虑“查询词是否出现在文档里”、“出现多少次”、“词本身是否稀有”、“文档长度是否过长”，我们可以看一下公式：
$$
Score(Q, D) = \sum_{i=1}^{n} IDF(q_i)\cdot
\frac{f(q_i, D)(k_1+1)}
{f(q_i, D) + k_1\left(1-b+b\cdot\frac{|D|}{avgdl}\right)}
$$
&lt;ul&gt;
&lt;li&gt;$IDF(q_i)$：查询词 $q_i$ 的逆文档频率，用于衡量一个词的普遍程度。越常见的词，IDF 值越低。如果一个词很少见，比如某个专有术语、型号名、算法名，那它更能说明“这篇文档和查询强相关”，贡献就高。&lt;/li&gt;
&lt;li&gt;$f(q_i, D)$：查询词 $q_i$ 在文档 $D$ 中的词频。但不是单纯越多越好，不是线性增长，会慢慢饱和。&lt;/li&gt;
&lt;li&gt;$|D|$：文档 $D$ 的长度。这是归一化修正要用的，否则长文本天然包含更多词更占便宜。&lt;/li&gt;
&lt;li&gt;$avgdl$：集合中所有文档的平均长度。&lt;/li&gt;
&lt;li&gt;$k_1, b$：可调节的超参数。$k_1$ 用于控制词频饱和度（一个词在文档中出现 10 次和 100 次，其重要性增长并非线性），$b$ 用于控制文档长度归一化的程度。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 密集向量&lt;/h2&gt;
&lt;p&gt;密集向量，也常被称为“语义向量”，是通过深度学习模型学习到的数据（如文本、图像）的低维、稠密的浮点数表示。这些向量旨在将原始数据映射到一个连续的、充满意义的“语义空间”中来捕捉“语义”或“概念”。&lt;/p&gt;
&lt;p&gt;其主要优点是能够理解同义词、近义词和上下文关系，泛化能力强，在语义搜索任务中表现卓越。但缺点也同样明显：可解释性差（向量中的每个维度通常没有具体的物理意义），需要大量数据和算力进行模型训练，且对于未登录词（OOV）的处理相对困难。&lt;/p&gt;
&lt;p&gt;OOV（Out-of-Vocabulary）未登录词：指在模型训练时没有出现在词汇表中，但在实际使用时遇到的新词汇。例如，如果模型训练时词汇表中没有&quot;ChatGPT&quot;这个词，那么在实际应用中遇到它时就是OOV。传统的稀疏向量方法（如BM25）对OOV词汇会完全忽略，而现代的密集向量方法通过子词分割（如BPE、WordPiece）可以更好地处理OOV问题。&lt;/p&gt;
&lt;h1&gt;二、混合检索的方法&lt;/h1&gt;
&lt;p&gt;混合检索通常并行执行两种检索算法，然后将两组异构的结果集融合成一个统一的排序列表。以下是两种主流的融合策略。&lt;/p&gt;
&lt;h2&gt;1. 倒数排序融合 (Reciprocal Rank Fusion, RRF)&lt;/h2&gt;
&lt;p&gt;RRF 不关心不同检索系统的原始得分，只关心每个文档在各自结果集中的排名。其思想是：一个文档在不同检索系统中的排名越靠前，它的最终得分就越高。其计分公式为：
$$
RRF_{score}(d) = \sum_{i=1}^{k} \frac{1}{rank_i(d) + c}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$d$ 是待评分的文档。&lt;/li&gt;
&lt;li&gt;$k$ 是检索系统的数量（这里是 2，即稀疏和密集）。&lt;/li&gt;
&lt;li&gt;$rank_i(d)$ 是文档 $d$ 在第 $i$ 个检索系统中的排名。&lt;/li&gt;
&lt;li&gt;$c$ 是一个常数（通常设为 60），用于降低排名靠前文档的相对权重，实现更稳健的排名融合。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 加权线性组合&lt;/h2&gt;
&lt;p&gt;这种方法需要先将不同检索系统的得分进行归一化（例如，统一到 0-1 区间），然后通过一个权重参数 α 来进行线性组合。&lt;/p&gt;
&lt;p&gt;$$
Hybrid_{score} = \alpha \cdot Dense_{score} + (1 - \alpha) \cdot Sparse_{score}
$$&lt;/p&gt;
&lt;p&gt;通过调整 α 的值，可以灵活地控制语义相似性与关键词匹配在最终排序中的贡献比例。例如，在电商搜索中，可以调高关键词的权重；而在智能问答中，则可以侧重于语义。&lt;/p&gt;
&lt;h2&gt;3. 区别、优势与局限&lt;/h2&gt;
&lt;p&gt;线性加权融合的是 dense 和 sparse 的原始分数，因此要求不同检索器的分数具有一定可比性；RRF 融合的是各检索器中的排名，不依赖分数尺度，因此通常更稳健，也更常用于实际的多路检索融合。&lt;/p&gt;
&lt;p&gt;两种方法的优势与局限如下：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;优势&lt;/th&gt;
&lt;th&gt;局限&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;召回率与准确率高：能同时捕获关键词和语义，显著优于单一检索。&lt;/td&gt;
&lt;td&gt;计算资源消耗大：需要同时维护和查询两套索引。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;灵活性强：可通过融合策略和权重调整，适应不同业务场景。&lt;/td&gt;
&lt;td&gt;参数调试复杂：融合权重等超参数需要反复实验调优。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;容错性好：关键词检索可部分弥补向量模型对拼写错误或罕见词的敏感性。&lt;/td&gt;
&lt;td&gt;可解释性仍是挑战：融合后的结果排序理由难以直观分析。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h1&gt;三、用Milvus实现混合检索&lt;/h1&gt;
&lt;p&gt;下面直接阅读实例代码即可&lt;/p&gt;
&lt;h2&gt;1. 定义Collection&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import json
import os
os.environ[&quot;HF_ENDPOINT&quot;] = &quot;https://hf-mirror.com&quot;
import numpy as np
from pymilvus import connections, MilvusClient, FieldSchema, CollectionSchema, DataType, Collection, AnnSearchRequest, RRFRanker
from pymilvus.model.hybrid import BGEM3EmbeddingFunction

# 1. 初始化设置
COLLECTION_NAME = &quot;dragon_hybrid_demo&quot;
MILVUS_URI = &quot;http://localhost:19530&quot;  # 服务器模式
DATA_PATH = &quot;../../data/C4/metadata/dragon.json&quot;  # 相对路径
BATCH_SIZE = 50

# 2. 连接 Milvus 并初始化嵌入模型
print(f&quot;--&amp;gt; 正在连接到 Milvus: {MILVUS_URI}&quot;)
connections.connect(uri=MILVUS_URI)

print(&quot;--&amp;gt; 正在初始化 BGE-M3 嵌入模型...&quot;)
ef = BGEM3EmbeddingFunction(use_fp16=False, device=&quot;cpu&quot;)
print(f&quot;--&amp;gt; 嵌入模型初始化完成。密集向量维度: {ef.dim[&apos;dense&apos;]}&quot;)

# 3. 创建 Collection
milvus_client = MilvusClient(uri=MILVUS_URI)
if milvus_client.has_collection(COLLECTION_NAME):
    print(f&quot;--&amp;gt; 正在删除已存在的 Collection &apos;{COLLECTION_NAME}&apos;...&quot;)
    milvus_client.drop_collection(COLLECTION_NAME)

fields = [
    FieldSchema(name=&quot;pk&quot;, dtype=DataType.VARCHAR, is_primary=True, auto_id=True, max_length=100),
    FieldSchema(name=&quot;img_id&quot;, dtype=DataType.VARCHAR, max_length=100),
    FieldSchema(name=&quot;path&quot;, dtype=DataType.VARCHAR, max_length=256),
    FieldSchema(name=&quot;title&quot;, dtype=DataType.VARCHAR, max_length=256),
    FieldSchema(name=&quot;description&quot;, dtype=DataType.VARCHAR, max_length=4096),
    FieldSchema(name=&quot;category&quot;, dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name=&quot;location&quot;, dtype=DataType.VARCHAR, max_length=128),
    FieldSchema(name=&quot;environment&quot;, dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name=&quot;sparse_vector&quot;, dtype=DataType.SPARSE_FLOAT_VECTOR),
    FieldSchema(name=&quot;dense_vector&quot;, dtype=DataType.FLOAT_VECTOR, dim=ef.dim[&quot;dense&quot;])
]

# 如果集合不存在，则创建它及索引
if not milvus_client.has_collection(COLLECTION_NAME):
    print(f&quot;--&amp;gt; 正在创建 Collection &apos;{COLLECTION_NAME}&apos;...&quot;)
    schema = CollectionSchema(fields, description=&quot;关于龙的混合检索示例&quot;)
    # 创建集合
    collection = Collection(name=COLLECTION_NAME, schema=schema, consistency_level=&quot;Strong&quot;)
    print(&quot;--&amp;gt; Collection 创建成功。&quot;)

    # 创建索引
    print(&quot;--&amp;gt; 正在为新集合创建索引...&quot;)
    sparse_index = {&quot;index_type&quot;: &quot;SPARSE_INVERTED_INDEX&quot;, &quot;metric_type&quot;: &quot;IP&quot;}
    collection.create_index(&quot;sparse_vector&quot;, sparse_index)
    print(&quot;稀疏向量索引创建成功。&quot;)

    dense_index = {&quot;index_type&quot;: &quot;AUTOINDEX&quot;, &quot;metric_type&quot;: &quot;IP&quot;}
    collection.create_index(&quot;dense_vector&quot;, dense_index)
    print(&quot;密集向量索引创建成功。&quot;)

collection = Collection(COLLECTION_NAME)
collection.load()
print(f&quot;--&amp;gt; Collection &apos;{COLLECTION_NAME}&apos; 已加载到内存。&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. BGE-M3双向量生成&lt;/h2&gt;
&lt;p&gt;BGE-M3 作为向量生成器，它能够同时生成稀疏向量和密集向量。&lt;/p&gt;
&lt;p&gt;首先加载数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if collection.is_empty:
    print(f&quot;--&amp;gt; Collection 为空，开始插入数据...&quot;)
    with open(DATA_PATH, &apos;r&apos;, encoding=&apos;utf-8&apos;) as f:
        dataset = json.load(f)

    docs, metadata = [], []
    for item in dataset:
        parts = [
            item.get(&apos;title&apos;, &apos;&apos;),
            item.get(&apos;description&apos;, &apos;&apos;),
            item.get(&apos;location&apos;, &apos;&apos;),
            item.get(&apos;environment&apos;, &apos;&apos;),
        ]
        docs.append(&apos; &apos;.join(filter(None, parts)))
        metadata.append(item)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后，我们生成向量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;print(&quot;--&amp;gt; 正在生成向量嵌入...&quot;)
embeddings = ef(docs)
print(&quot;--&amp;gt; 向量生成完成。&quot;)

# 获取两种向量
sparse_vectors = embeddings[&quot;sparse&quot;]    # 稀疏向量：词频统计
dense_vectors = embeddings[&quot;dense&quot;]      # 密集向量：语义编码
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后，我们在Collection中批量插入数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 为每个字段准备批量数据
img_ids = [doc[&quot;img_id&quot;] for doc in metadata]
paths = [doc[&quot;path&quot;] for doc in metadata]
titles = [doc[&quot;title&quot;] for doc in metadata]
descriptions = [doc[&quot;description&quot;] for doc in metadata]
categories = [doc[&quot;category&quot;] for doc in metadata]
locations = [doc[&quot;location&quot;] for doc in metadata]
environments = [doc[&quot;environment&quot;] for doc in metadata]

# 插入数据
collection.insert([
    img_ids, paths, titles, descriptions, categories, locations, environments,
    sparse_vectors, dense_vectors
])
collection.flush()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前面实现过了Milvus，这里的代码阅读应该没什么困难，就是稍微麻烦了点。&lt;/p&gt;
&lt;h2&gt;3. 实现混合检索&lt;/h2&gt;
&lt;p&gt;milvus中已经封装好了RRF算法，首先，我们生成查询向量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 6. 执行搜索
search_query = &quot;悬崖上的巨龙&quot;
search_filter = &apos;category in [&quot;western_dragon&quot;, &quot;chinese_dragon&quot;, &quot;movie_character&quot;]&apos;
top_k = 5

print(f&quot;\n{&apos;=&apos;*20} 开始混合搜索 {&apos;=&apos;*20}&quot;)
print(f&quot;查询: &apos;{search_query}&apos;&quot;)
print(f&quot;过滤器: &apos;{search_filter}&apos;&quot;)

# 生成查询向量
query_embeddings = ef([search_query])
dense_vec = query_embeddings[&quot;dense&quot;][0]
sparse_vec = query_embeddings[&quot;sparse&quot;]._getrow(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后，使用 RRF 算法进行混合检索，通过 milvus 封装的 RRFRanker 实现。RRFRanker 的核心参数是 k 值（默认60），用于控制 RRF 算法中的排序平滑程度：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 定义搜索参数
search_params = {&quot;metric_type&quot;: &quot;IP&quot;, &quot;params&quot;: {}}

# 先执行单独的搜索
print(&quot;\n--- [单独] 密集向量搜索结果 ---&quot;)
dense_results = collection.search(
    [dense_vec],
    anns_field=&quot;dense_vector&quot;,
    param=search_params,
    limit=top_k,
    expr=search_filter,
    output_fields=[&quot;title&quot;, &quot;path&quot;, &quot;description&quot;, &quot;category&quot;, &quot;location&quot;, &quot;environment&quot;]
)[0]

for i, hit in enumerate(dense_results):
    print(f&quot;{i+1}. {hit.entity.get(&apos;title&apos;)} (Score: {hit.distance:.4f})&quot;)
    print(f&quot;    路径: {hit.entity.get(&apos;path&apos;)}&quot;)
    print(f&quot;    描述: {hit.entity.get(&apos;description&apos;)[:100]}...&quot;)

print(&quot;\n--- [单独] 稀疏向量搜索结果 ---&quot;)
sparse_results = collection.search(
    [sparse_vec],
    anns_field=&quot;sparse_vector&quot;,
    param=search_params,
    limit=top_k,
    expr=search_filter,
    output_fields=[&quot;title&quot;, &quot;path&quot;, &quot;description&quot;, &quot;category&quot;, &quot;location&quot;, &quot;environment&quot;]
)[0]

for i, hit in enumerate(sparse_results):
    print(f&quot;{i+1}. {hit.entity.get(&apos;title&apos;)} (Score: {hit.distance:.4f})&quot;)
    print(f&quot;    路径: {hit.entity.get(&apos;path&apos;)}&quot;)
    print(f&quot;    描述: {hit.entity.get(&apos;description&apos;)[:100]}...&quot;)

print(&quot;\n--- [混合] 稀疏+密集向量搜索结果 ---&quot;)
# 创建 RRF 融合器
rerank = RRFRanker(k=60)

# 创建搜索请求
dense_req = AnnSearchRequest([dense_vec], &quot;dense_vector&quot;, search_params, limit=top_k)
sparse_req = AnnSearchRequest([sparse_vec], &quot;sparse_vector&quot;, search_params, limit=top_k)

# 执行混合搜索
results = collection.hybrid_search(
    [sparse_req, dense_req],
    rerank=rerank,
    limit=top_k,
    output_fields=[&quot;title&quot;, &quot;path&quot;, &quot;description&quot;, &quot;category&quot;, &quot;location&quot;, &quot;environment&quot;]
)[0]

# 打印最终结果
for i, hit in enumerate(results):
    print(f&quot;{i+1}. {hit.entity.get(&apos;title&apos;)} (Score: {hit.distance:.4f})&quot;)
    print(f&quot;    路径: {hit.entity.get(&apos;path&apos;)}&quot;)
    print(f&quot;    描述: {hit.entity.get(&apos;description&apos;)[:100]}...&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终输出结果如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--- [单独] 密集向量搜索结果 ---
1. 悬崖上的白龙 (Score: 0.7219)
    路径: ../../data/C3/dragon/dragon02.png
    描述: 一头雄伟的白色巨龙栖息在悬崖边缘，背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿，是典型的西方奇幻生物。...
2. 中华金龙 (Score: 0.5131)
    路径: ../../data/C3/dragon/dragon06.png
    描述: 一条金色的中华龙在祥云间盘旋，它身形矫健，龙须飘逸，展现了东方神话中龙的威严与神圣。...
3. 驯龙高手：无牙仔 (Score: 0.5119)
    路径: ../../data/C3/dragon/dragon05.png
    描述: 在电影《驯龙高手》中，主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳，下方是岛屿和海洋，画面充满了冒险与友谊。...

--- [单独] 稀疏向量搜索结果 ---
1. 悬崖上的白龙 (Score: 0.2319)
    路径: ../../data/C3/dragon/dragon02.png
    描述: 一头雄伟的白色巨龙栖息在悬崖边缘，背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿，是典型的西方奇幻生物。...
2. 中华金龙 (Score: 0.0923)
    路径: ../../data/C3/dragon/dragon06.png
    描述: 一条金色的中华龙在祥云间盘旋，它身形矫健，龙须飘逸，展现了东方神话中龙的威严与神圣。...
3. 驯龙高手：无牙仔 (Score: 0.0691)
    路径: ../../data/C3/dragon/dragon05.png
    描述: 在电影《驯龙高手》中，主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳，下方是岛屿和海洋，画面充满了冒险与友谊。...

--- [混合] 稀疏+密集向量搜索结果 ---
1. 悬崖上的白龙 (Score: 0.0328)
    路径: ../../data/C3/dragon/dragon02.png
    描述: 一头雄伟的白色巨龙栖息在悬崖边缘，背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿，是典型的西方奇幻生物。...
2. 中华金龙 (Score: 0.0320)
    路径: ../../data/C3/dragon/dragon06.png
    描述: 一条金色的中华龙在祥云间盘旋，它身形矫健，龙须飘逸，展现了东方神话中龙的威严与神圣。...
3. 霸王龙的怒吼 (Score: 0.0318)
    路径: ../../data/C3/dragon/dragon03.png
    描述: 史前时代的霸王龙张开血盆大口，发出震天的怒吼。在它身后，几只翼龙在阴沉的天空中盘旋，展现了白垩纪的原始力量。...
4. 奔跑的奶龙 (Score: 0.0313)
    路径: ../../data/C3/dragon/dragon04.png
    描述: 一只Q版的黄色小恐龙，有着大大的绿色眼睛和友善的微笑。是一部动画中的角色，非常可爱。...
5. 驯龙高手：无牙仔 (Score: 0.0310)
    路径: ../../data/C3/dragon/dragon05.png
    描述: 在电影《驯龙高手》中，主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳，下方是岛屿和海洋，画面充满了冒险与友谊。...
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Python的ACM模式基础</title><link>https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/acm%E6%A8%A1%E5%BC%8F/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/acm%E6%A8%A1%E5%BC%8F/</guid><description>练习ACM模式的几种情况。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;一、单行输入&lt;/h1&gt;
&lt;p&gt;对10个整数从小到大排序，处理10个整数，并打印出来&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 程序入口
# 输入
if __name__==&quot;__main__&quot;:
    # 去掉输如字符串的前后空格，然后分割成数组
    # 输入元素：
    # 4 85 3 234 45 345 345 122 30 12
    # 下面这句也可以写成list(map(lambda x: int(x),input().strip().split()))
    data = list(map(int,input().strip().split()))
    data.sort()
    print(&quot; &quot;.join(map(str,data)))
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;二、多行输入，不确定行数&lt;/h1&gt;
&lt;p&gt;给定正整数A和B，计算A+B&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    # 不确定函数，我们需要while循环
    # 输入元素：
    &quot;&quot;&quot;
    1 1
    2 3
    &quot;&quot;&quot;
    while True:
        # 这里的map是一个迭代器
        # 用try来接受文件结束错误
        try:
            a,b = map(int,input().strip().split())
            print(a+b)
        except EOFError:
            break
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;三、多行输入，确定行数&lt;/h1&gt;
&lt;p&gt;输入一个n，然后再输入n组数据样例，返回他们的和&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    for i in range(n):
        a, b = map(int,input().strip().split())
        print(a+b)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;四、多行输入，指定结束符号&lt;/h1&gt;
&lt;p&gt;还是两数之和，指定0 0结束&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    for i in range(n):
        a,b = map(int,input().strip().split())
        if a == 0 and b == 0:
            break
        print(a + b)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;五、不确定行数，不确定个数&lt;/h1&gt;
&lt;p&gt;输入多组数据样例，每组数据占一行，每一行的输入划分为第一个数和其他数，第一个数代表后面多少数求和，返回和。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    while True:
        try:
            data = list(map(int,input().strip().split()))
            n,array = data[0],data[1:]
            print(sum(array))
        except EOFError:
            break
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;六、确定行数不确定个数&lt;/h1&gt;
&lt;p&gt;先输入n，然后给n行，每行个数不确定，返回和&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    for _ in range(n):
        data = list(map(int,input().strip().split()))
        print(sum(data))
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;七、多行输入，不确定类型&lt;/h1&gt;
&lt;p&gt;给定 &lt;code&gt;n&lt;/code&gt;，然后输入 &lt;code&gt;n&lt;/code&gt; 行，每行包含成绩单信息。&lt;/p&gt;
&lt;p&gt;输出三行，第一行语文最好的学生姓名学科分数，第二行数学成绩最好的学生姓名学科分数，第三行英语成绩最好的学生姓名学科分数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def number_or_chars(x):
    if x.isdigit():
        return int(x)
    else:
        return x

if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    info = []
    for _ in range(n):
        data = list(map(number_or_chars,input().strip().split()))
        info.append(data)

    max_c = 0
    max_c_id = 0
    max_m = 0
    max_m_id = 0
    max_e = 0
    max_e_id = 0

    for i, each in enumerate(info):
        if max_c &amp;lt; each[3]:
            max_c = each[3]
            max_c_id = i
        if max_m &amp;lt; each[5]:
            max_m = each[5]
            max_m_id = i
        if max_e &amp;lt; each[7]:
            max_e = each[7]
            max_e_id = i

    print(info[max_c_id][0],info[max_c_id][2],info[max_c_id][3])
    print(info[max_m_id][0],info[max_m_id][4],info[max_m_id][5])
    print(info[max_e_id][0],info[max_e_id][6],info[max_e_id][7])
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;八、&lt;code&gt;sys.stdin&lt;/code&gt; 的几种常见写法&lt;/h1&gt;
&lt;p&gt;等价于不断读到 &lt;code&gt;EOF&lt;/code&gt; 为止，一行一行读入，不用自己写 &lt;code&gt;while True + try/except&lt;/code&gt;。一般有如下三种情况：&lt;/p&gt;
&lt;h2&gt;1. 逐行读到 EOF&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import sys

for line in sys.stdin:
    nums = list(map(int, line.split()))
    print(sum(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 一次性读完&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import sys

data = sys.stdin.read().split()
nums = list(map(int, data))
print(sum(nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 代替 &lt;code&gt;input()&lt;/code&gt; 提速&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import sys

input = sys.stdin.readline

n = int(input().strip())
for _ in range(n):
    a, b = map(int, input().split())
    print(a + b)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;九、&lt;code&gt;ast.literal_eval&lt;/code&gt; 解析嵌套结构&lt;/h1&gt;
&lt;p&gt;有些题目的本地输入会直接写成 Python 风格的嵌套列表，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[1,2],[3,4],[5,6]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者像随机链表那样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[7,null],[13,0],[11,4],[10,2],[1,0]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时候如果手动 &lt;code&gt;split&lt;/code&gt; 会很麻烦，用 &lt;code&gt;ast.literal_eval&lt;/code&gt; 往往更省事。&lt;/p&gt;
&lt;p&gt;它的作用是：安全地把“字符串形式的字面量”解析成真正的 Python 数据结构。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

if __name__ == &quot;__main__&quot;:
    line = input().strip()
    data = ast.literal_eval(line)
    print(data)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如输入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[1,2],[3,4],[5,6]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[1, 2], [3, 4], [5, 6]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果输入里有 &lt;code&gt;null&lt;/code&gt;，Python 不认识，需要先替换成 &lt;code&gt;None&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

if __name__ == &quot;__main__&quot;:
    line = input().strip()
    data = ast.literal_eval(line.replace(&quot;null&quot;, &quot;None&quot;))
    print(data)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个方法特别适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;二维数组&lt;/li&gt;
&lt;li&gt;嵌套列表&lt;/li&gt;
&lt;li&gt;树、图、随机链表这类带结构的本地模拟输入&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;注意这里一般用的是 &lt;code&gt;ast.literal_eval&lt;/code&gt;，而不是 &lt;code&gt;eval&lt;/code&gt;，因为前者更安全，只会解析字面量，不会执行任意代码。&lt;/p&gt;
</content:encoded></item><item><title>算法总结-动态规划</title><link>https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92/</guid><description>总结汇总一下动态规划技巧。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;动态规划的核心理解&lt;/h1&gt;
&lt;h2&gt;什么是动态规划&lt;/h2&gt;
&lt;p&gt;动态规划（DP）的本质是把一个大问题拆成&lt;strong&gt;有重叠的子问题&lt;/strong&gt;，每个子问题只算一次，用数组/哈希表存下来，然后从这些子问题的解推导出原问题的解。&lt;/p&gt;
&lt;p&gt;两个核心要素：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;最优子结构&lt;/strong&gt;：大问题的最优解可以由子问题的最优解推导出来&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;重叠子问题&lt;/strong&gt;：同一个子问题会被反复遇到，不缓存就会重复计算&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;简单说：遇到一个问题的状态可以由前面的状态推导出来，而且前面的状态会被多次用到，就该想到 DP。&lt;/p&gt;
&lt;h2&gt;DP 和递归、记忆化搜索的关系&lt;/h2&gt;
&lt;p&gt;三者本质是&lt;strong&gt;同一张 DAG（有向无环图）上的不同遍历方式&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;递归（自顶向下）→ 加 @cache → 记忆化搜索 → 翻转方向 → DP 数组（自底向上）
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;方向&lt;/th&gt;
&lt;th&gt;存储&lt;/th&gt;
&lt;th&gt;典型写法&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;无缓存递归&lt;/td&gt;
&lt;td&gt;顶→底&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;指数级重复计算&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;记忆化搜索&lt;/td&gt;
&lt;td&gt;顶→底&lt;/td&gt;
&lt;td&gt;cache/hash&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@cache&lt;/code&gt; + dfs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DP 数组&lt;/td&gt;
&lt;td&gt;底→顶&lt;/td&gt;
&lt;td&gt;数组&lt;/td&gt;
&lt;td&gt;for 循环填表&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;同一道题，&lt;code&gt;dfs(i)&lt;/code&gt; 的参数 &lt;code&gt;i&lt;/code&gt; 就是 &lt;code&gt;dp[i]&lt;/code&gt; 的下标，返回值就是 &lt;code&gt;dp[i]&lt;/code&gt; 的值。记忆化搜索和 DP 数组完全等价，只是遍历方向相反。树形 DP 天然适合递归写法（树没有重叠子问题），线性/网格 DP 适合数组写法（空间压缩更方便）。&lt;/p&gt;
&lt;h2&gt;什么时候想到动态规划&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;题目求最值、方案数、可行性&lt;/strong&gt;（&quot;最多&quot;&quot;最少&quot;&quot;有多少种&quot;&quot;能不能&quot;）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个步骤有选择，选择影响后续&lt;/strong&gt;（选或不选、选哪个）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态可以用有限个变量描述&lt;/strong&gt;（位置、容量、剩余次数……）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一看就有大量重复子问题&lt;/strong&gt;，暴力会超时&lt;/li&gt;
&lt;li&gt;数据范围：n ≤ 10^4~10^5（一维 DP），n ≤ 500（二维 DP），n ≤ 20（状态压缩 DP）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;反面信号：要你输出所有具体方案（不是方案数）→ 回溯；数据范围 n ≥ 10^6 且无特殊结构 → 贪心或数学。&lt;/p&gt;
&lt;h2&gt;动态规划五步法&lt;/h2&gt;
&lt;p&gt;拿到一道 DP 题按这五步走：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;定义 dp 含义&lt;/strong&gt;：dp[i] 或 dp[i][j] 到底代表什么？一句话说清楚&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;推导转移方程&lt;/strong&gt;：当前状态能从哪些前驱状态推导过来？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;初始化&lt;/strong&gt;：基础情况（空串、边界、第一行/列）填什么？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;确定遍历顺序&lt;/strong&gt;：从小到大还是从大到小？外层是什么？保证依赖的前驱先被算好&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;返回值&lt;/strong&gt;：最终答案在 dp 数组的哪个位置？&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;状态定义&lt;/h2&gt;
&lt;p&gt;最关键的一步。常见的 dp 含义模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;以 i 结尾&lt;/strong&gt;：&lt;code&gt;dp[i]&lt;/code&gt; = 以位置 i 结尾时的最优值（LC53 最大子数组和、LC300 LIS）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前 i 个元素&lt;/strong&gt;：&lt;code&gt;dp[i]&lt;/code&gt; = 考虑前 i 个元素时的最优值（LC198 打家劫舍）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;区间 [i, j]&lt;/strong&gt;：&lt;code&gt;dp[i][j]&lt;/code&gt; = 区间上的最优值（LC5 回文子串、LC312 戳气球）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;双序列&lt;/strong&gt;：&lt;code&gt;dp[i][j]&lt;/code&gt; = s 的前 i 个和 t 的前 j 个的结果（LC1143 LCS、LC72 编辑距离）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态机&lt;/strong&gt;：定义多个状态互相转移（股票买卖、LC968 监控二叉树）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mask 压缩&lt;/strong&gt;：&lt;code&gt;dp[mask]&lt;/code&gt; 用二进制表示集合（LC698 划分子集）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;好的定义让转移方程自然涌现，坏的定义令人想破头。如果转移很别扭，大概率是状态定义歪了。&lt;/p&gt;
&lt;h2&gt;状态转移&lt;/h2&gt;
&lt;p&gt;从&quot;前一个状态 + 当前选择&quot;推导当前状态。常见模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;选或不选&lt;/strong&gt;：&lt;code&gt;dp[i] = max(dp[i-1], dp[i-2] + val[i])&lt;/code&gt;（打家劫舍）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;选哪个&lt;/strong&gt;：&lt;code&gt;dp[i] = max(dp[i-1], dp[0..i-1]) + cost&lt;/code&gt;（LIS、零钱兑换）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两端收缩&lt;/strong&gt;：&lt;code&gt;dp[i][j]&lt;/code&gt; 由 &lt;code&gt;dp[i+1][j]&lt;/code&gt; 和 &lt;code&gt;dp[i][j-1]&lt;/code&gt; 转移（回文 DP）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;中间切分&lt;/strong&gt;：&lt;code&gt;dp[i][j]&lt;/code&gt; 由 &lt;code&gt;dp[i][k] + dp[k+1][j]&lt;/code&gt; 转移（区间 DP）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两路汇合&lt;/strong&gt;：&lt;code&gt;dp[i][j]&lt;/code&gt; 由 &lt;code&gt;dp[i-1][j]&lt;/code&gt; 和 &lt;code&gt;dp[i][j-1]&lt;/code&gt; 转移（网格路径、LCS）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;写不出转移时，画个小例子在纸上手动推三步，看相邻状态之间的关系。&lt;/p&gt;
&lt;h2&gt;初始化&lt;/h2&gt;
&lt;p&gt;初始化决定了&quot;空状态&quot;和&quot;边界状态&quot;的值，直接影响后续所有填表。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;0/1 背包&lt;/strong&gt;：&lt;code&gt;dp[0] = 0&lt;/code&gt;（容量 0 时价值 0），其余 &lt;code&gt;-∞&lt;/code&gt; 或 &lt;code&gt;False&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;计数 DP&lt;/strong&gt;：&lt;code&gt;dp[0] = 1&lt;/code&gt;（&quot;什么都不选&quot;算一种方案）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;双序列 DP&lt;/strong&gt;：&lt;code&gt;dp[i][0]&lt;/code&gt; 和 &lt;code&gt;dp[0][j]&lt;/code&gt; 对应空串的情况（多开一圈的好处）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;路径 DP&lt;/strong&gt;：第一行和第一列特殊处理（或用越界保护统一）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;小技巧：多开一圈（&lt;code&gt;dp = [0] * (n+1)&lt;/code&gt;）让 &lt;code&gt;dp[0]&lt;/code&gt; 代表空前缀，避免单独处理边界。&lt;/p&gt;
&lt;h2&gt;遍历顺序&lt;/h2&gt;
&lt;p&gt;核心原则：&lt;strong&gt;计算 dp[i] 时，它依赖的所有状态必须已经算好&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一维：通常是正序（依赖 &lt;code&gt;i-1&lt;/code&gt;），背包倒序（避免物品重复使用）&lt;/li&gt;
&lt;li&gt;二维网格：&lt;code&gt;i&lt;/code&gt; 正序 &lt;code&gt;j&lt;/code&gt; 正序（依赖上方和左方）&lt;/li&gt;
&lt;li&gt;回文 DP：&lt;code&gt;i&lt;/code&gt; 倒序 &lt;code&gt;j&lt;/code&gt; 正序（依赖 &lt;code&gt;i+1&lt;/code&gt; 和 &lt;code&gt;j-1&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;区间 DP：按区间长度递增（短区间先算）&lt;/li&gt;
&lt;li&gt;树形 DP：后序遍历（子节点先算）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不确定顺序时，画一个二维表，标出 &lt;code&gt;(i,j)&lt;/code&gt; 依赖哪些格子，箭头方向就是遍历方向。&lt;/p&gt;
&lt;h2&gt;返回值&lt;/h2&gt;
&lt;p&gt;不是所有 DP 的答案都在最后一个位置。常见情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;dp[n] 或 dp[m][n]&lt;/strong&gt;：整个问题的答案（LCS、编辑距离）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;max(dp)&lt;/strong&gt;：最优值可能出现在任意位置（最大子数组和、LIS）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dp[0][n-1]&lt;/strong&gt;：整个区间/字符串的答案（回文 DP、区间 DP）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;min(dp[:2])&lt;/strong&gt;：状态机的最终状态（监控二叉树、股票问题）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dp[(1&amp;lt;&amp;lt;n)-1]&lt;/strong&gt;：全选状态（状态压缩 DP）&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;空间压缩&lt;/h2&gt;
&lt;p&gt;如果 &lt;code&gt;dp[i]&lt;/code&gt; 只依赖固定的前几项，可以用滚动变量代替整个数组：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;依赖 &lt;code&gt;dp[i-1]&lt;/code&gt; 和 &lt;code&gt;dp[i-2]&lt;/code&gt; → 用两个变量（斐波那契、爬楼梯）&lt;/li&gt;
&lt;li&gt;依赖 &lt;code&gt;dp[i-1][j]&lt;/code&gt; 和 &lt;code&gt;dp[i][j-1]&lt;/code&gt; → 用一维数组滚动（二维路径）&lt;/li&gt;
&lt;li&gt;依赖上一行的邻近列 → 用两个一维数组交替（下降路径最小和）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;空间压缩不是必需的，先写出完整 DP 跑通，再考虑优化。&lt;/p&gt;
&lt;h2&gt;Python 中常用写法&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 基础一维 DP
dp = [0] * (n + 1)
for i in range(1, n + 1):
    dp[i] = max(dp[i-1], dp[i-2] + val)

# 多开一圈避免边界判断
dp = [[0] * (n + 1) for _ in range(m + 1)]

# 越界保护（用 INF）
from_up = dp[i-1][j] if i &amp;gt; 0 else float(&apos;inf&apos;)

# 记忆化搜索（等价 DP）
from functools import cache
@cache
def dfs(i, j):
    ...

# 滚动变量替代数组
cur, prev = 1, 1
for i in range(2, n + 1):
    cur, prev = cur + prev, cur
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;入门一维 DP&lt;/h1&gt;
&lt;h2&gt;斐波那契模型&lt;/h2&gt;
&lt;h3&gt;LC509 - 斐波那契数&lt;/h3&gt;
&lt;p&gt;感觉反复写过很多遍了，也罢，放在dp专题里面再写一次吧，注意一下范围问题。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def fib(n:int):
    if n&amp;lt;2:
        return n
    dp = [0]*(n+1)
    dp[0] = 0
    dp[1] = 1
    for i in range(2,n+1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC70 - 爬楼梯&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def climbStairs(self, n: int) -&amp;gt; int:
    dp = [0] * (n+1)
    dp[0] = 1
    dp[1] = 1
    for i in range(2,n+1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC746 - 使用最小花费爬楼梯&lt;/h3&gt;
&lt;p&gt;最小花费爬楼梯问题，我们将dp[i]定义为到i级台阶的最小费用。这里注意，真实台阶的下标是0 ~ n-1，我们求的dp[n]就是已经算上最后的台阶的费用了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minCostClimbingStairs(self, cost: list[int]) -&amp;gt; int:
    n = len(cost)
    dp = [0] * (n+1)
    for i in range(2,n+1):
        dp[i] = min(dp[i-2]+cost[i-2],dp[i-1]+cost[i-1])
    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;打家劫舍模型&lt;/h2&gt;
&lt;h3&gt;LC198 - 打家劫舍&lt;/h3&gt;
&lt;p&gt;打家劫舍是经典的dp题，也是经典的带限制0-1背包。每个房间可以选择偷或不偷，但是相邻的房子不能被偷。&lt;/p&gt;
&lt;p&gt;我们用dp[i]表示偷的房子编号以i结尾的最大金额，其转移方程为 dp[i] = max(dp[i-2]+nums[i], dp[i-1])。它表示要么从两个前偷过来，要么这里不偷沿用上一个的金额。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def rob(self, nums: list[int]) -&amp;gt; int:
    n = len(nums)
    dp = [0] * n
    if n == 0:
        return 0
    if n == 1:
        return nums[0]
    dp[0] = nums[0]
    dp[1] = max(nums[0],nums[1])
    for i in range(2,n):
        dp[i] = max(dp[i-2]+nums[i],dp[i-1])
    return dp[n-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意的就是下标越界问题。所以dp题都应该先考虑让初始条件成立（如果有下标初始化先保证下标有意义），然后再进行转移。&lt;/p&gt;
&lt;h3&gt;LC213 - 打家劫舍 II&lt;/h3&gt;
&lt;p&gt;与打家劫舍的区别是房子现在围成一圈。环形打家劫舍关键限制就是第0间和第n-1间不能同时偷，因为它们相邻。所以合法的方案只能在不偷最后一间，不偷第一间中选。我们直接写出打家劫舍，然后return两种情况的max就行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def rob_linear(self, nums: List[int]) -&amp;gt; int:
    n = len(nums)
    dp = [0] * (n + 1)
    if n == 0:
        return 0
    if n == 1:
        return nums[0]
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2, n):
        dp[i] = max(dp[i - 2] + nums[i], dp[i - 1])
    return dp[n - 1]

def rob(self, nums: List[int]) -&amp;gt; int:
    n = len(nums)
    if n == 0:
        return 0
    if n == 1:
        return nums[0]
    # 复用打家劫舍1
    return max(self.rob_linear(nums[0 : n - 1]), self.rob_linear(nums[1:n]))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC337 - 打家劫舍 III&lt;/h3&gt;
&lt;p&gt;树形打家劫舍。实际上就是限制父树和子树不能同时被偷。我们复用树的结构，存rob和not_rob两个值，来表示偷/不偷的时候最大金额。这样，就有两个转移方程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果偷了当前节点，左右孩子都不能偷了：rob = root.val + left.not_rob + right.not_rob&lt;/li&gt;
&lt;li&gt;如果不偷当前节点，那左右孩子可以偷，也可以不偷，各自取最大：not_rob = max(left.rob, left.not_rob) + max(right.rob, right.not_rob)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;树形打家劫舍里，当前节点的答案依赖左右子树的答案。所以就是标准的后序问题，按照左右中即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def rob(root):
    def dfs(node):
        if not node:
            return (0, 0)

        left_rob, left_not_rob = dfs(node.left)
        right_rob, right_not_rob = dfs(node.right)

        rob_cur = node.val + left_not_rob + right_not_rob
        not_rob_cur = max(left_rob, left_not_rob) + max(right_rob, right_not_rob)

        return (rob_cur, not_rob_cur)

    return max(dfs(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;最大子数组模型&lt;/h2&gt;
&lt;h3&gt;LC53 - 最大子数组和&lt;/h3&gt;
&lt;p&gt;经典dp，注意最大子数组和不一定出现在最后即可。还有注意下标问题，dp最容易出现下标问题，还有边界问题（只有1个数据的时候），尽量脑子里想一下，实在不行就保护0、1、2的时候。&lt;/p&gt;
&lt;p&gt;dp[i] 的含义是，是以位置i结尾的时候的最大子数组和。因此dp数组只要开到n-1下标&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxSubArray(self, nums: list[int]) -&amp;gt; int:
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    for i in range(1,n):
        dp[i] = max(dp[i-1]+nums[i],nums[i])
    return max(dp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，这一题只要求一个最大值，我们可以不维护整个dp数组。我们用curr_max维持遍历的时候目前的最大连续子数组和，然后ans来记录全局答案即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxSubArray(self, nums: list[int]) -&amp;gt; int:
    n = len(nums)
    curr_max = nums[0]
    ans = nums[0]
    for i in range(1,n):
        curr_max = max(curr_max+nums[i],nums[i])
        ans = max(ans,curr_max)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC918 - 环形子数组的最大和&lt;/h3&gt;
&lt;p&gt;环形最大子数组问题可以被拆成两类问题，即最大子数组不跨越首尾、最大子数组跨越首尾。前者回退到LC53，而后者则是可以用总会-最小子数组和。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxSubarraySumCircular(nums):
    total = sum(nums)

    cur_max = max_sum = nums[0]
    cur_min = min_sum = nums[0]

    for x in nums[1:]:
        cur_max = max(x, cur_max + x)
        max_sum = max(max_sum, cur_max)

        cur_min = min(x, cur_min + x)
        min_sum = min(min_sum, cur_min)

    # 如果都是负数，也不允许一个都不选。因为可以用 total - min_sum = 0 表示一个都不选。
    if max_sum &amp;lt; 0:
        return max_sum

    return max(max_sum, total - min_sum)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;一维 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 基础模板：dp[i] = f(dp[i-1], dp[i-2], ...)
n = len(data)
dp = [0] * n
dp[0] = init_val
for i in range(1, n):
    dp[i] = max(dp[i-1], dp[i-2] + val[i])  # 具体转移看题
return dp[-1]  # 或 max(dp)

# 空间压缩：依赖固定前几项时用两个变量
cur, prev = init_cur, init_prev
for i in range(2, n + 1):
    cur, prev = update(cur, prev), cur
return cur
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一维 DP 的核心是搞清楚 &lt;code&gt;dp[i]&lt;/code&gt; 代表什么：是以 i 结尾，还是前 i 个元素。&lt;/p&gt;
&lt;h1&gt;二维网格 DP&lt;/h1&gt;
&lt;h2&gt;网格路径的核心理解&lt;/h2&gt;
&lt;p&gt;网格 DP 的状态依赖上方和左方（或更多方向），典型的转移模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dp[i][j] = f(dp[i-1][j], dp[i][j-1]) + cost[i][j]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;遍历顺序通常是 &lt;code&gt;i&lt;/code&gt; 正序 &lt;code&gt;j&lt;/code&gt; 正序，保证左和上的状态先算好。&lt;/p&gt;
&lt;h3&gt;LC62 - 不同路径&lt;/h3&gt;
&lt;p&gt;路径题是经典的二维dp。我们用同样尺寸的dp网格，dp[i][j]表示到坐标ij处的路径数，然后返回dp[m-1][n-1]就行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def uniquePaths(self, m: int, n: int) -&amp;gt; int:
    dp = [[0]* n for _ in range(m)]
    # 初始化边界
    for i in range(m):
        dp[i][0] = 1
    for j in range(n):
        dp[0][j] = 1
    
    for i in range(1,m):
        for j in range(1,n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    return dp[m-1][n-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC63 - 不同路径 II&lt;/h3&gt;
&lt;p&gt;存在表示为1的石头，不能走。其实就是在石头位置把dp设置为0，这样对后面的路贡献也清掉了。另外边界情况时，如果遇到石头，那后面的也要全部置为0。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def uniquePathsWithObstacles(obstacleGrid):
    m, n = len(obstacleGrid), len(obstacleGrid[0])
    dp = [[0] * n for _ in range(m)]

    if obstacleGrid[0][0] == 1:
        return 0

    dp[0][0] = 1

    for j in range(1, n):
        if obstacleGrid[0][j] == 0:
            dp[0][j] = dp[0][j - 1]

    for i in range(1, m):
        if obstacleGrid[i][0] == 0:
            dp[i][0] = dp[i - 1][0]

    for i in range(1, m):
        for j in range(1, n):
            if obstacleGrid[i][j] == 1:
                dp[i][j] = 0
            else:
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

    return dp[m - 1][n - 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一题为了简洁，我们也可以不单独拿出来初始化边界，而是在遍历中判断，把石头的dp变成0，其他统一用带边界判断if-else的转移方程（统一规划越界方向0）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def uniquePathsWithObstacles(obstacleGrid):
    m, n = len(obstacleGrid), len(obstacleGrid[0])
    dp = [[0] * n for _ in range(m)]

    dp[0][0] = 1 if obstacleGrid[0][0] == 0 else 0

    for i in range(m):
        for j in range(n):
            if obstacleGrid[i][j] == 1:
                dp[i][j] = 0
            # 不单独初始化也要单独弄掉零零
            elif i == 0 and j == 0:
                continue
            else:
                from_up = dp[i - 1][j] if i &amp;gt; 0 else 0
                from_left = dp[i][j - 1] if j &amp;gt; 0 else 0
                dp[i][j] = from_up + from_left

    return dp[-1][-1]

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC64 - 最小路径和&lt;/h3&gt;
&lt;p&gt;我们用dp[i][j]存储到当前路径，可以用越界统一正无穷保护。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minPathSum(self, grid: list[list[int]]) -&amp;gt; int:
    m, n = len(grid), len(grid[0])
    dp = [[0]*n for _ in range(m)]
    for i in range(m):
        for j in range(n):
            if i == 0 and j == 0:
                dp[i][j] = grid[0][0]
            else:
                from_up = dp[i-1][j] if i&amp;gt;0 else float(&apos;INF&apos;)
                from_left = dp[i][j-1] if j&amp;gt;0 else float(&apos;INF&apos;)
                dp[i][j] = min(from_left,from_up)+grid[i][j]
    return dp[-1][-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC931 - 下降路径最小和&lt;/h3&gt;
&lt;p&gt;下降元素最小和问题，我们可以先用dp[i][j]表示到达坐标ij的下降路径最小和，然后转移方程就是dp[i][j] = min(dp[i-1][j], dp[i-1][j-1], dp[i-1][j+1]) + matrix[i][j]，越界保护正无穷即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minFallingPathSum(self, matrix: list[list[int]]) -&amp;gt; int:
    m, n = len(matrix), len(matrix[0])
    dp = [[0] * n for _ in range(m)]
    for i in range(m):
        for j in range(n):
            if i == 0:
                dp[i][j] = matrix[i][j]
            else:
                a = dp[i-1][j-1] if j-1 &amp;gt;= 0 else float(&apos;INF&apos;)
                b = dp[i-1][j]
                c = dp[i-1][j+1] if j+1 &amp;lt; n else float(&apos;INF&apos;)
                dp[i][j] = min(a,b,c) + matrix[i][j]
    return min(dp[-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC120 - 三角形最小路径和&lt;/h3&gt;
&lt;p&gt;跟上一题差不多的，用dp[i][j]存这里可以走到的最小路径和，j最多只到i+1的位置。转移方程式 dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + triangle[i][j]。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minimumTotal(self, triangle: list[list[int]]) -&amp;gt; int:
    n = len(triangle)
    dp = [[0]*n for _ in range(n)]
    for i in range(n):
        for j in range(i+1):
            if i == 0:
                dp[i][j] = triangle[i][j]
            else:
                a = dp[i-1][j] if j&amp;lt;=i-1 else float(&apos;INF&apos;)
                b = dp[i-1][j-1] if j-1&amp;gt;=0 else float(&apos;INF&apos;)
                dp[i][j] = min(a,b) + triangle[i][j]
    return min(dp[-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;二维网格 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 基础模板
m, n = len(grid), len(grid[0])
dp = [[0] * n for _ in range(m)]
dp[0][0] = grid[0][0]
for i in range(1, m): dp[i][0] = dp[i-1][0] + grid[i][0]
for j in range(1, n): dp[0][j] = dp[0][j-1] + grid[0][j]
for i in range(1, m):
    for j in range(1, n):
        dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
return dp[-1][-1]

# 越界保护版（免去初始化边界）
for i in range(m):
    for j in range(n):
        if i == 0 and j == 0:
            dp[i][j] = grid[0][0]
        else:
            from_up = dp[i-1][j] if i &amp;gt; 0 else float(&apos;inf&apos;)
            from_left = dp[i][j-1] if j &amp;gt; 0 else float(&apos;inf&apos;)
            dp[i][j] = min(from_up, from_left) + grid[i][j]
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;背包 DP&lt;/h1&gt;
&lt;h2&gt;背包问题的核心理解&lt;/h2&gt;
&lt;p&gt;背包问题的本质是：&lt;strong&gt;有一个容量限制，每个物品有体积和价值，在不超过容量的前提下做选择，使得总价值最大（或判断能否装满）&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;三种基本变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;0-1 背包&lt;/strong&gt;：每个物品只能选或不选。遍历时&lt;strong&gt;倒序&lt;/strong&gt;（保证每个物品只用一次）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;完全背包&lt;/strong&gt;：每个物品可以选无数次。遍历时&lt;strong&gt;正序&lt;/strong&gt;（允许同一物品被重复使用）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多重背包&lt;/strong&gt;：每个物品有有限个。可转化为 0-1 背包或用计数数组优化&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;此外还有&lt;strong&gt;分组背包&lt;/strong&gt;（每组最多选一个）等变体。&lt;/p&gt;
&lt;p&gt;背包 DP 的一个重要技巧是 &lt;strong&gt;&quot;求什么就设什么为 dp 值&quot;&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;目标&lt;/th&gt;
&lt;th&gt;dp 含义&lt;/th&gt;
&lt;th&gt;转移核心&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;最大价值&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j]&lt;/code&gt; = 容量 j 时的最大价值&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j] = max(dp[j], dp[j-v] + w)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;能否装满&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j]&lt;/code&gt; = 能否凑出 j&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j] = dp[j] or dp[j-num]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;装满的方案数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j]&lt;/code&gt; = 凑出 j 的方案数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j] += dp[j-num]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最少物品数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j]&lt;/code&gt; = 凑出 j 的最少物品数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dp[j] = min(dp[j], dp[j-num] + 1)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;0-1 背包&lt;/h2&gt;
&lt;h3&gt;0-1 背包模板&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 0-1 背包：求最大价值
def knapsack01(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for i in range(len(weights)):
        for j in range(capacity, weights[i] - 1, -1):  # 倒序！
            dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
    return dp[capacity]

# 0-1 背包：能否装满（布尔背包）
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
    for j in range(target, num - 1, -1):
        dp[j] = dp[j] or dp[j - num]

# 0-1 背包：方案数
dp = [0] * (target + 1)
dp[0] = 1
for num in nums:
    for j in range(target, num - 1, -1):
        dp[j] += dp[j - num]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;倒序是关键——正序会让同一物品被重复使用，变成完全背包。&lt;/p&gt;
&lt;h3&gt;LC416 - 分割等和子集&lt;/h3&gt;
&lt;p&gt;这一题其实就是组合总和 II 的「判定版」，而且 target 固定为总和的一半。完全可以直接拿过来用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def canPartition(nums):
    total = sum(nums)
    if total % 2:
        return False

    target = total // 2
    nums.sort()

    @cache
    def dfs(start,target):
        if target&amp;lt;0:
            return False
        if target == 0:
            return True
        
        for i in range(start,len(nums)):
            if dfs(i+1,target-nums[i]):
                return True
        return False
    return dfs(0,target)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是，如果直接拿过来有一个注意事项，就是组合总和II是需要答案组合，还不能重复，所以有一个跳过下一层选同样数字的设置（那边每个数字只能用一次），这里要去除。然后，那里需要组合的具体数字，这里只需要True/False，有大量重复状态，可以@cache加速。折腾完了之后，你会发现可以惊人打败5%了。。&lt;/p&gt;
&lt;p&gt;所以，因为不需要具体选择，只要“能不能凑出来”这个状态的结果，这一题的做法还得是0/1背包滚动更新。0/1 背包解决的是“每个东西只能选一次，在容量/目标限制下选出最优或判断能否达成”的问题。&lt;/p&gt;
&lt;p&gt;我们设 dp[j] 为能不能凑出来数字j，不能重复选的0/1背包，做法是倒序遍历，这样能保证每个num只被使用一次。有转移方程 dp[j] = dp[j] or dp[j-num]。&lt;/p&gt;
&lt;p&gt;这里有点绕，我们来举个例子理解这个问题。比如[1,5,11,5]，那么我们就要找能不能切分成两个和为11的。首先，我们肯定会让dp[0] = True，因为凑个0默认都是可以凑的，不选呗。然后，我们看到第一个数字1，从大到小更新，按照转移方程，dp[11] = dp[11] or dp[10]，dp[10] = dp[10] or dp[9]…… 这样 检查下去，都是False，没有什么影响，直到看到 dp[1] = dp[1] or dp[0] 的时候，dp[1]会被变成True。&lt;/p&gt;
&lt;p&gt;你发现了没有，倒序更新不会乱动后面的dp，但是给目前能凑出来的结果dp[j-num]，转移到了dp[j]。如果正序的话，这个结果就会错误被传递下去，一个 dp[0] = True 和一个 num = 1可以直接全部推成True。&lt;/p&gt;
&lt;p&gt;至于倒序只到 num-1 比较好理解，因为num不可能凑出来比 num 还小的数字。好了，至此，我们就可以写出这道0/1背包的经典入门题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def canPartition(nums):
    total = sum(nums)
    if total % 2:
        return False

    target = total // 2
    dp = [False] * (target + 1)
    dp[0] = True

    for num in nums:
        for j in range(target, num - 1, -1):
            dp[j] = dp[j] or dp[j - num]

    return dp[target]

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC494 - 目标和&lt;/h3&gt;
&lt;p&gt;这一题当然可以用带cache的dfs来做，如果想按照01背包来做，需要做一些改动。如果被加号选中的数字为P，减号选中的为N，则有 P-N = target，而且 P + N = S（数组总和），我们可以直接得到 P = (S + target) / 2，问题成功被转化为nums中选一些数让他们和为(S + target) / 2，问有多少选法。&lt;/p&gt;
&lt;p&gt;然后，不是求是否能选，而是有多少选法时，转移方程也稍微变一下，变成 dp[j] += dp[j-num] 即可（dp初始化全0）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def findTargetSumWays(nums, target):
    total = sum(nums)

    if abs(target) &amp;gt; total:
        return 0

    if (total + target) % 2 == 1:
        return 0

    bag = (total + target) // 2

    dp = [0] * (bag + 1)
    dp[0] = 1

    for num in nums:
        for j in range(bag, num - 1, -1):
            dp[j] += dp[j - num]

    return dp[bag]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样写时间上也是爆杀dfs，不错。&lt;/p&gt;
&lt;h3&gt;LC1049 - 最后一块石头的重量 II&lt;/h3&gt;
&lt;p&gt;这题跟上一题有点像，但是不再提供目标，而是要去求 min(abs(P - N))。两堆谁大谁小无所谓，我们假设 P 小，N - P = (S - P) - P = S - 2P。所以差值最小的情况，就是 P 最接近 S/2 的情况：从 stones 里选一些石头，每个石头最多选一次，让它们的和尽量接近但不超过 sum(stones) // 2。&lt;/p&gt;
&lt;p&gt;这就是标准的01背包问题！01背包本来的样子，就是接近但不超过容量要装的最大重量石头。我们按照最大重量背包问题直接求解，转移方程从布尔背包变成了 dp[j] = max(dp[j], dp[j - stone] + stone)。这里就是P最接近 S/2 的重量，然后我们用总重量 - 2P 就达成了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def lastStoneWeightII(stones):
    total = sum(stones)
    target = total // 2

    dp = [0] * (target + 1)

    for stone in stones:
        for j in range(target, stone - 1, -1):
            dp[j] = max(dp[j], dp[j - stone] + stone)

    return total - 2 * dp[target]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;完全背包&lt;/h2&gt;
&lt;h3&gt;完全背包模板&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 完全背包：最少硬币数
dp = [INF] * (amount + 1)
dp[0] = 0
for coin in coins:
    for j in range(coin, amount + 1):  # 正序！
        dp[j] = min(dp[j], dp[j - coin] + 1)

# 完全背包：方案数（组合）
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:              # 物品在外
    for j in range(coin, amount + 1):  # 容量在内
        dp[j] += dp[j - coin]

# 排列数：容量在外，物品在内
dp = [0] * (target + 1)
dp[0] = 1
for j in range(1, target + 1):  # 容量在外
    for num in nums:            # 物品在内
        if j &amp;gt;= num:
            dp[j] += dp[j - num]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正序 vs 倒序决定了同一物品能否被多次选取。组合 vs 排列由物品循环和容量循环的嵌套顺序决定。&lt;/p&gt;
&lt;h2&gt;多重背包&lt;/h2&gt;
&lt;p&gt;每个物品有数量限制 &lt;code&gt;count[i]&lt;/code&gt;。简单做法是将每个物品拆成 &lt;code&gt;count[i]&lt;/code&gt; 个 0-1 背包物品（复杂度过高），优化用二进制拆分或计数数组。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 多重背包：二进制拆分法
for w, v, cnt in items:
    k = 1
    while k &amp;lt;= cnt:
        # 打包成 k 个物品
        for j in range(capacity, w * k - 1, -1):
            dp[j] = max(dp[j], dp[j - w * k] + v * k)
        cnt -= k
        k *= 2
    if cnt:  # 剩余的再打一包
        for j in range(capacity, w * cnt - 1, -1):
            dp[j] = max(dp[j], dp[j - w * cnt] + v * cnt)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;分组背包&lt;/h2&gt;
&lt;p&gt;每组物品最多选一个。物品分好组后，外层枚举组，内层倒序枚举容量，最内层枚举组内物品。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for group in groups:
    for j in range(capacity, -1, -1):
        for w, v in group:
            if j &amp;gt;= w:
                dp[j] = max(dp[j], dp[j - w] + v)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;背包 DP 遍历顺序总结&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;变体&lt;/th&gt;
&lt;th&gt;物品循环&lt;/th&gt;
&lt;th&gt;容量循环&lt;/th&gt;
&lt;th&gt;内层方向&lt;/th&gt;
&lt;th&gt;复杂度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0-1 背包&lt;/td&gt;
&lt;td&gt;外层&lt;/td&gt;
&lt;td&gt;内层&lt;/td&gt;
&lt;td&gt;倒序&lt;/td&gt;
&lt;td&gt;O(n * C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;完全背包（组合）&lt;/td&gt;
&lt;td&gt;外层&lt;/td&gt;
&lt;td&gt;内层&lt;/td&gt;
&lt;td&gt;正序&lt;/td&gt;
&lt;td&gt;O(n * C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;完全背包（排列）&lt;/td&gt;
&lt;td&gt;内层&lt;/td&gt;
&lt;td&gt;外层&lt;/td&gt;
&lt;td&gt;正序&lt;/td&gt;
&lt;td&gt;O(n * C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;多重背包&lt;/td&gt;
&lt;td&gt;外层（拆分后）&lt;/td&gt;
&lt;td&gt;内层&lt;/td&gt;
&lt;td&gt;倒序&lt;/td&gt;
&lt;td&gt;O(Σcnt * C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;分组背包&lt;/td&gt;
&lt;td&gt;外层&lt;/td&gt;
&lt;td&gt;内层（中）&lt;/td&gt;
&lt;td&gt;倒序&lt;/td&gt;
&lt;td&gt;O(G * k * C)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;硬币可以选无数次，所以这一题是一个完全背包问题。因此，我们也不需要倒序遍历，直接正序从coin走到底就行。dp[j] 表示凑到 j 所需要的最小硬币数，转移方程 dp[j] = min(dp[j], dp[j-coin] + 1)。初始化dp的时候，用一个很大的数字表示不可达。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def coinChange(self, coins: list[int], amount: int) -&amp;gt; int:
    INF = amount + 1
    dp = [INF] * (amount + 1)
    dp[0] = 0

    for coin in coins:
        for j in range(coin, amount + 1):
            dp[j] = min(dp[j], dp[j - coin] + 1)

    return dp[amount] if dp[amount] != INF else -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们写题的时候一定要先捋清楚dp的含义，这样才知道如何写初始化和转移。&lt;/p&gt;
&lt;h3&gt;LC518 - 零钱兑换 II&lt;/h3&gt;
&lt;p&gt;这一题零钱兑换，依旧无限硬币，但是求的是凑出amount的方案。我们定义dp[j]为凑出j的方案数，显然全部初始化为0。转移方程为 dp[j] += dp[j-coin] 即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def change(self, amount: int, coins: list[int]) -&amp;gt; int:
    dp = [0] * (amount + 1)
    dp[0] = 1
    for coin in coins:
        for j in range(coin,amount+1):
            dp[j] += dp[j-coin]
    return dp[amount]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC279 - 完全平方数&lt;/h3&gt;
&lt;p&gt;给你一个整数 n ，返回和为 n 的完全平方数的最少数量。这一题实际上也是完全背包，可以选的从1到无限，求最少和数字为n的个数。我们设dp[j]表示凑出j的最小完全平方数个数，转移方程 dp[j] = min(dp[j-i*i]+1,dp[j])。&lt;/p&gt;
&lt;p&gt;但是这一题的候选没给有限的数组，我们可以先求出平方小于等于n的最大数字k（这里如果看出来n就是完全平方数那就直接返回1了），然后从1开始试到k。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def numSquares(n: int) -&amp;gt; int:
    INF = n + 1
    dp = [INF] * (n + 1)
    dp[0] = 0

    for i in range(1, int(n ** 0.5) + 1):
        square = i * i
        for j in range(square, n + 1):
            dp[j] = min(dp[j], dp[j - square] + 1)

    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC377 - 组合总和 IV&lt;/h3&gt;
&lt;p&gt;这一题的核心在于“顺序不同算不同方案”，普通的完全背包 dp[j] += dp[j-num] 会默认按照nums顺序调用，无法区分 1+2 和 2+1 。所以，我们要改变格式，将容量放到外层，然后物品放在内层，然后用容量大于物体来判断是否转移：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def combinationSum4(nums, target):
    dp = [0] * (target + 1)
    dp[0] = 1

    for j in range(1, target + 1):
        for num in nums:
            if j &amp;gt;= num:
                dp[j] += dp[j - num]

    return dp[target]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们注意区分完全背包中的“组合数”和“排列数”，前者物品在外容量在内，后者容量在外物品在内。&lt;/p&gt;
&lt;h2&gt;多重背包&lt;/h2&gt;
&lt;h2&gt;分组背包&lt;/h2&gt;
&lt;h2&gt;背包 DP 遍历顺序总结&lt;/h2&gt;
&lt;h1&gt;子序列 DP&lt;/h1&gt;
&lt;h2&gt;子序列 DP 的核心理解&lt;/h2&gt;
&lt;p&gt;子序列 DP 处理的是&quot;从序列中按顺序选一部分&quot;的问题。核心区分两个概念：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;子数组/子串（Subarray）&lt;/strong&gt;：必须连续，&lt;code&gt;dp[i]&lt;/code&gt; 通常以 i 结尾&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;子序列（Subsequence）&lt;/strong&gt;：可以不连续，&lt;code&gt;dp[i]&lt;/code&gt; 需要遍历前面所有位置&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;连续版转移通常只有一种去向（i-1），不连续版需要枚举前驱。&lt;/p&gt;
&lt;p&gt;单序列 DP 的 &lt;code&gt;dp[i]&lt;/code&gt; 几乎总是&quot;以 i 结尾&quot;的含义。双序列 DP 则是经典 &lt;code&gt;dp[i][j]&lt;/code&gt; 二维表。&lt;/p&gt;
&lt;h2&gt;单序列 DP&lt;/h2&gt;
&lt;h3&gt;LC300 - 最长递增子序列&lt;/h3&gt;
&lt;p&gt;这一题最长递增子序列，特别要注意的是，子序列可以不用连续！设 dp[j] 为到为止j位置的最长递增子序列长度，我们需要遍历前面的i，如果满足nums[i] &amp;lt; nums[j]，则转移 dp[j] = max(dp[j], dp[i] + 1)。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def lengthOfLIS(nums):
    n = len(nums)
    dp = [1] * n

    for j in range(n):
        for i in range(j):
            if nums[i] &amp;lt; nums[j]:
                dp[j] = max(dp[j], dp[i] + 1)

    return max(dp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，这一题有更快的解法，即贪心+二分的方法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def lengthOfLIS(nums):
    tails = []

    for x in nums:
        left, right = 0, len(tails)

        while left &amp;lt; right:
            mid = (left + right) // 2

            if tails[mid] &amp;gt;= x:
                right = mid
            else:
                left = mid + 1

        if left == len(tails):
            tails.append(x)
        else:
            tails[left] = x

    return len(tails)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC674 - 最长连续递增序列&lt;/h3&gt;
&lt;p&gt;本题和最大连续子数组和相似，都是继续上一个或者另起，我们设 dp[j] 为到j位置为止的最长递增序列，转移方程为：dp[j] = dp[j - 1] + 1 if nums[j - 1] &amp;lt; nums[j] else 1。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def findLengthOfLCIS(self, nums: list[int]) -&amp;gt; int:
    n = len(nums)
    dp = [1] * n
    for j in range(1,n):
        dp[j] = dp[j - 1] + 1 if nums[j - 1] &amp;lt; nums[j] else 1
    return max(dp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后这一题也同样可以使用两个变量解决：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def findLengthOfLCIS(self, nums: list[int]) -&amp;gt; int:
    cur = 1
    ans = 1
    for j in range(1, len(nums)):
        if nums[j - 1] &amp;lt; nums[j]:
            cur += 1
        else:
            cur = 1
        ans = max(ans, cur)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC32 - 最长有效括号&lt;/h3&gt;
&lt;p&gt;同样是dp[i] = 以 i 位置结尾的某种最优结果，但是这一题更复杂一点，属于更复杂的连续结构。有效括号一定会以右括号结尾，所以左括号对应的dp都是0；而当遇到右括号的时候，有两种情况，一种情况是()，这样可以直接把这两个算成有效，然后按照 dp[i-2]+2 来转移就行；另一种情况是))，这样的话，我们需要先找到前一个括号的有效括号长度（即dp[i-1]），然后找到这段有效括号之前的位置即 pre = i - dp[i-1] - 1，如果这个位置大于0且为 ( 则可以配队，转移为 dp[i] += dp[pre-1] if pre&amp;gt;=1 else 0。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestValidParentheses(s: str) -&amp;gt; int:
    n = len(s)
    dp = [0] * n
    ans = 0

    for i in range(1, n):
        if s[i] == &apos;)&apos;:
            if s[i - 1] == &apos;(&apos;:
                dp[i] = 2 + (dp[i - 2] if i &amp;gt;= 2 else 0)
            else:
                pre = i - dp[i - 1] - 1
                if pre &amp;gt;= 0 and s[pre] == &apos;(&apos;:
                    dp[i] = dp[i - 1] + 2 + (dp[pre - 1] if pre &amp;gt;= 1 else 0)

            ans = max(ans, dp[i])

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC139 - 单词拆分&lt;/h3&gt;
&lt;p&gt;判断s能不能拆分成wordDict词表中的单词，一个直观的方法是直接搜索切分。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def wordBreak(self, s: str, wordDict: list[str]) -&amp;gt; bool:
    wordSet = set(wordDict)
    @cache
    def dfs(start):
        if start == len(s):
            return True
        for end in range(start,len(s)):
            curr = s[start:end+1]
            if curr in wordSet:
                if dfs(end + 1):
                    return True
        return False
    return dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;加上cache勉强不超时。不过相信也看出来了，这种题目，不要你具体的切分、答案，只问能不能达成状态，搜索一般不是最好的选择。我们可以用dp[j]表示到下标j为止都可以切分，然后思考遍历词表，就有转移方程: dp[j] = dp[j-len(word)] or dp[j] if s[j-len(word)+1:j+1] in wordSet。（你也看出来了，这样比较变扭，两边都+1，所以我们重新定义状态dp[j]为s[0:j]能拆分吧，这样dp多一位，式子更好看）：
dp[j] = dp[j-len(word)] or dp[j] if s[j-len(word):j] in wordSet。&lt;/p&gt;
&lt;p&gt;不用wordSet，我们可以把word循环放内部，这样转移方程就进一步简化为了如果 s[j-len(word):j] == word 并且 dp[j-len(word)] == True 那么 dp[j] = True。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def wordBreak(self, s: str, wordDict: list[str]) -&amp;gt; bool:
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True

    for j in range(1, n + 1):
        for word in wordDict:
            l = len(word)
            if j &amp;gt;= l and dp[j - l] and s[j - l:j] == word:
                dp[j] = True
                break
    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;双序列 DP&lt;/h2&gt;
&lt;h3&gt;LC1143 - 最长公共子序列&lt;/h3&gt;
&lt;p&gt;这道题有两个字符串，求他们的最长公共子序列，我们用dp[i][j]表示到text1的i位置和text2的j位置的最长公共子序列，实际上就是我们遍历判断的时候，有两种情况的转移：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当text1[i] == text2[j]，有dp[i][j] = dp[i-1][j-1] + 1&lt;/li&gt;
&lt;li&gt;当不相等的时候，可能是i移动也可能是j移动，所以转移为 dp[i][j] = max(dp[i-1][j], dp[i][j-1])&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们多开一圈，让ij表示选中text1和text2的数量，最后返回的dp[m][n]即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestCommonSubsequence(self, text1: str, text2: str) -&amp;gt; int:
    m = len(text1)
    n = len(text2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(1,m+1):
        for j in range(1,n+1):
            if text1[i-1] == text2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意这里我们通过多开一圈，把“空前缀”的基础状态显式放进 dp 表里；它们本来就应该是 0，而数组默认就是 0，所以不用额外初始化。否则，如果直接用i、j表示下标的话，需要自己初始化边界。&lt;/p&gt;
&lt;p&gt;所以，要不要多开一圈，请仔细思考初始化。&lt;/p&gt;
&lt;h3&gt;LC1035 - 不相交的线&lt;/h3&gt;
&lt;p&gt;仔细一想，其实就是最长公共子序列的长度啊。演都不演了，直接搬过来就行了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -&amp;gt; int:
    m = len(nums1)
    n = len(nums2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(1,m+1):
        for j in range(1,n+1):
            if nums1[i-1] == nums2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC718 - 最长重复子数组&lt;/h3&gt;
&lt;p&gt;这一题是最长公共子序列的连续版（子数组），需要更改转移方程。我们可以借鉴以往的经验，选择dp[i][j]为以 nums1[i-1] 和 nums2[j-1] 结尾的最长公共连续子数组长度，初始化为0。如果nums1[i] == nums2[j]，那么dp[i][j] = dp[i-1][j-1] + 1，一样，但是如果出现 nums1[i] != nums[j]，就要直接归0。很容易理解，如果当前两个数不相等，那以它们结尾的公共连续子数组根本不存在，所以长度只能是 0，而不要求连续的题目中才可能去更新尝试任意一个数组回退一位去寻找最大的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def findLength(nums1, nums2):
    m, n = len(nums1), len(nums2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    ans = 0

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if nums1[i - 1] == nums2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
                ans = max(ans, dp[i][j])
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC583 - 两个字符串的删除操作&lt;/h3&gt;
&lt;p&gt;其实吧，这一题可以直接求最长重复子序列，然后多出来都就是都要删除的。比如我们把LC1143拿过来，然后最后返回一个删除的数目：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minDistance(self, word1: str, word2: str) -&amp;gt; int:
    m = len(word1)
    n = len(word2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(1,m+1):
        for j in range(1,n+1):
            if word1[i-1] == word2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return m-dp[m][n]+n-dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过锻炼dp思维，可以重新dp一下。我们用dp[i][j]表示以word1[i-1]和word2[j-1]结尾要想一样需要删除的字符串数目。当两者一样的时候，不用删，也就是dp[i][j] = dp[i-1][j-1]；当两者不一样的时候，可以选择任意一方删除 dp[i][j] = min(dp[i-1][j]+1,dp[i][j-1]+1)。这里最容易想错，你可以理解为当前的dp[i][j]可能是i-1删一个过来或者j-1删一个过来，删就是无条件跳过。&lt;/p&gt;
&lt;p&gt;但是这一题需要额外注意的是初始状态，不是全0。当i或j等于0的时候，需要把另外一个删除才行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minDistance(self, word1: str, word2: str) -&amp;gt; int:
    m = len(word1)
    n = len(word2)
    dp = [[0]*(n+1) for _ in range(m+1)]

    for i in range(m+1):
        dp[i][0] = i
    
    for j in range(n+1):
        dp[0][j] = j

    for i in range(1,m+1):
        for j in range(1,n+1):
            if word1[i-1] == word2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(dp[i-1][j]+1,dp[i][j-1]+1)
    return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC72 - 编辑距离&lt;/h3&gt;
&lt;p&gt;这一题在上一题上更进一步，有三种选择，我们再次尝试思考一下。初始化跟上一题一样，然后，如果word1[i] == word2[j]，则dp[i][j] = dp[i-1][j-1] 不用操作。如果不一样，可能是三种操作造成的，承接这三种情况的状态，分别是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;插入了一个字符，其实就是相对于word1，word2多走一格，即 dp[i][j] = dp[i][j-1]+1 。比如，abce和abcde，当走到c和d的时候，我们word1插入一个d即可，相对而言，就是都选c的dp加上一步操作即可。&lt;/li&gt;
&lt;li&gt;删除了一个字符，也就是上一题的情况，word1多了一个字母，状态就是 dp[i][j] = dp[i-1][j] + 1。&lt;/li&gt;
&lt;li&gt;替换一个字符，就是 dp[i][j] = dp[i-1][j-1] + 1，直接替换不一样的结尾。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后，这三种情况选择一个最小的即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minDistance(self, word1: str, word2: str) -&amp;gt; int:
    m, n = len(word1), len(word2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1):
        dp[i][0] = i
    for j in range(n+1):
        dp[0][j] = j

    for i in range(1,m+1):
        for j in range(1,n+1):
            if word1[i-1] == word2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(
                    dp[i-1][j] + 1,
                    dp[i][j-1] + 1,
                    dp[i-1][j-1] + 1
                )
    return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC115 - 不同的子序列&lt;/h3&gt;
&lt;p&gt;这一题是给两个字符串s和t，统计s的子序列中t出现的个数。我们可以理解成，通过多少种删除操作，可以让s变成t，注意是只能删s。所以，两个不相等的时候，转移就是删一个s的得到 dp[i][j] = dp[i-1][j]；但是如果两个相等，情况不止是直接从dp[i-1][j-1]转移过来，也可能是删了一个重复相等去删一个s的情况（比如rabbb和rabb的时候，虽然相等，但是可以删一个b让后面t出现），所以转移其实是 dp[i][j] = dp[i-1][j-1] + dp[i-1][j]。&lt;/p&gt;
&lt;p&gt;删除操作的题目时，如果出现了一样，可以不用管直接从dp[i-1][j-1]转移即可，因为最小删除次数，对保留一对相等字符一定不会多操作。拿rabbb和rabb，就算不删，从这三个b中删除任何一个，都是一次操作，无所谓。但是换成本题，其实有三种删法，也就是答案是3，这就是转移方程差距的来源了。&lt;/p&gt;
&lt;p&gt;另外，边界初始化也要更改。当t为空的时候，其实只有一种序列出现在s中，就是空序列，也就是dp[i][0]全1。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def numDistinct(self, s: str, t: str) -&amp;gt; int:
    m = len(s)
    n = len(t)
    dp = [[0]*(n+1) for _ in range(m+1)]

    for i in range(m+1):
        dp[i][0] = 1

    for i in range(1,m+1):
        for j in range(1,n+1):
            if s[i-1] == t[j-1]:
                dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
            else:
                dp[i][j] = dp[i-1][j]
    return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;子序列 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 单序列不连续（LIS）
dp = [1] * n
for j in range(n):
    for i in range(j):
        if nums[i] &amp;lt; nums[j]:
            dp[j] = max(dp[j], dp[i] + 1)

# 单序列连续（LCIS）
dp = [1] * n
for j in range(1, n):
    if nums[j-1] &amp;lt; nums[j]:
        dp[j] = dp[j-1] + 1

# 双序列（LCS）
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
    for j in range(1, n+1):
        if s[i-1] == t[j-1]:
            dp[i][j] = dp[i-1][j-1] + 1
        else:
            dp[i][j] = max(dp[i-1][j], dp[i][j-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;回文 DP&lt;/h1&gt;
&lt;h2&gt;回文 DP 的核心理解&lt;/h2&gt;
&lt;p&gt;回文 DP 定义一个二维布尔数组 &lt;code&gt;dp[i][j]&lt;/code&gt; 表示 &lt;code&gt;s[i:j+1]&lt;/code&gt; 是否为回文。转移的核心逻辑是两端字符相等时，向里收缩：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;s[i] == s[j]&lt;/code&gt; 且 &lt;code&gt;j-i &amp;lt;= 2&lt;/code&gt; → 直接是回文（1~3 个字符）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s[i] == s[j]&lt;/code&gt; 且 &lt;code&gt;dp[i+1][j-1]&lt;/code&gt; 为真 → 是真回文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;遍历顺序是 &lt;code&gt;i&lt;/code&gt; 倒序（从右到左）、&lt;code&gt;j&lt;/code&gt; 正序（从左到右），因为 &lt;code&gt;dp[i][j]&lt;/code&gt; 依赖 &lt;code&gt;dp[i+1][j-1]&lt;/code&gt;（更靠里的小区间）。也可以按区间长度递增遍历。&lt;/p&gt;
&lt;p&gt;回文子序列不要求连续，不相等时可以跳过一端（和 LCS 类似）。&lt;/p&gt;
&lt;h3&gt;LC5 - 最长回文子串&lt;/h3&gt;
&lt;p&gt;这一题通常有两种解法，一种是中心扩散，一种是二维dp。中心扩散法时，我们定义一个expand函数，然后分奇偶从每个可能的中心开始调用expand，更新为较长的即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestPalindrome(self, s: str) -&amp;gt; str:
    def expand(i,j)-&amp;gt;tuple[int,int]:
        while i&amp;gt;=0 and j&amp;lt;len(s) and s[i] == s[j]:
            i-=1
            j+=1
        return i+1,j-1
    
    start = end = 0

    for i in range(len(s)):
        l1,r1 = expand(i,i)
        l2,r2 = expand(i,i+1)
        if r1 - l1 &amp;gt; end - start:
            start, end = l1, r1
        if r2 - l2 &amp;gt; end - start:
            start, end = l2, r2
    return s[start:end+1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而这一题的另一种做法，就是二维dp。我们用dp[i][j] = s[i:j+1] 是否是回文串，如果两端相等，当 j-i&amp;lt;=2 就直接是回文，否则要看里面的 s[i+1:j] 是否是回文。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestPalindrome(s: str) -&amp;gt; str:
    n = len(s)
    dp = [[False] * n for _ in range(n)]
    start = 0
    max_len = 1

    for i in range(n - 1, -1, -1):
        for j in range(i, n):
            if s[i] == s[j] and (j - i &amp;lt;= 2 or dp[i + 1][j - 1]):
                dp[i][j] = True
                if j - i + 1 &amp;gt; max_len:
                    start = i
                    max_len = j - i + 1

    return s[start:start + max_len]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里注意，i是倒序的，因为dp[i][j]依赖dp[i+1][j-1]，所以要先算靠里面的区间。&lt;/p&gt;
&lt;p&gt;另外，我们从长度出发，因为两个端点是相互依赖的，所以我们可以循环长度来做。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestPalindrome(s: str) -&amp;gt; str:
    # dp[i][j]表示s[i:j+1]是否为回文串
    n = len(s)
    dp = [[False]*n for _ in range(n)]
    for i in range(n):
        dp[i][i] = True

    # 要返回子字符串额外需要的变量
    start = 0
    max_len = 1

    for length in range(2,n+1):
        for i in range(n-length+1):
            j = i + length - 1 
            if s[i] != s[j]:
                dp[i][j] = False
            else:
                if length&amp;lt;=3:
                    dp[i][j] = True
                else:
                    dp[i][j] = dp[i+1][j-1]
            # 拿答案
            (start, max_len) = (i, length) if dp[i][j] and length&amp;gt;max_len else (start, max_len)

    return s[start:start+max_len]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC647 - 回文子串&lt;/h3&gt;
&lt;p&gt;这一题，统计回文子串的数目。我们也可以想到用dp来做，同样dp[i][j]表示s[i:j+1]，每次找到回文子串就+1返回ans即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def countSubstrings(s: str) -&amp;gt; int:
    n = len(s)
    dp = [[False] * n for _ in range(n)]
    ans = 0

    for i in range(n - 1, -1, -1):
        for j in range(i, n):
            if s[i] == s[j] and (j - i &amp;lt;= 2 or dp[i + 1][j - 1]):
                dp[i][j] = True
                ans += 1

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC516 - 最长回文子序列&lt;/h3&gt;
&lt;p&gt;这一题，回文子串变成了回文子序列，也就是说不要求连续了。我们知道，回到非连续的题，我们在不相等的时候，可以选择任意一遍移动继续去寻找，然后取最大/最小。所以这一题，两端不相等的时候，我们去找 dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])；而两端相等的时候，直接+2即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestPalindromeSubseq(s: str) -&amp;gt; int:
    n = len(s)
    dp = [[0] * n for _ in range(n)]

    for i in range(n - 1, -1, -1):
        dp[i][i] = 1
        for j in range(i + 1, n):
            if s[i] == s[j]:
                dp[i][j] = dp[i + 1][j - 1] + 2
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])

    return dp[0][n - 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里注意，返回的dp[0][n-1]，也就是整个串。&lt;/p&gt;
&lt;h3&gt;LC1312 - 让字符串成为回文串的最少插入次数&lt;/h3&gt;
&lt;p&gt;同理，这一题的转移不相等的时候可以写成 dp[i][j] = min(dp[i+1][j] + 1, dp[i][j-1] + 1)；相等的话dp[i][j] = dp[i+1][j-1]。（右边插入，等效于和左边往前走一步的dp+1一样结果，比如abcb，插入abcba，新dp等效于bcb+1）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minInsertions(s: str) -&amp;gt; int:
    n = len(s)
    dp = [[0] * n for _ in range(n)]

    for i in range(n - 1, -1, -1):
        for j in range(i + 1, n):
            if s[i] == s[j]:
                dp[i][j] = dp[i + 1][j - 1]
            else:
                dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1

    return dp[0][n - 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;回文 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 回文子串/子序列 DP，i 倒序 j 正序
for i in range(n-1, -1, -1):
    for j in range(i, n):
        if s[i] == s[j]:
            if j - i &amp;lt;= 2:
                dp[i][j] = True  # 或 j-i+1 (长度)
            else:
                dp[i][j] = dp[i+1][j-1]  # 或 dp[i+1][j-1] + 2

# 回文子序列（不连续）
for i in range(n-1, -1, -1):
    dp[i][i] = 1
    for j in range(i+1, n):
        if s[i] == s[j]:
            dp[i][j] = dp[i+1][j-1] + 2
        else:
            dp[i][j] = max(dp[i+1][j], dp[i][j-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;股票买卖 DP&lt;/h1&gt;
&lt;h2&gt;股票 DP 的核心理解&lt;/h2&gt;
&lt;p&gt;股票类 DP 统一使用两个核心状态（状态机）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;hold&lt;/strong&gt;：今天结束后手里有股票，所能达到的最大利润（买入花掉的钱也算进去，所以通常为负或减少）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;cash&lt;/strong&gt;：今天结束后手里没股票，所能达到的最大利润&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每天的转移方程只有两个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cash = max(cash, hold + price)        # 不动 vs 卖出
hold = max(hold, X - price)           # 不动 vs 买入
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;所有变体的唯一区别就是买入时用来减的 &lt;code&gt;X&lt;/code&gt; 是什么&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目&lt;/th&gt;
&lt;th&gt;X&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LC121（限 1 次）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;只能买一次，没有历史利润&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LC122（无限次）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用累积利润继续买&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LC123（限 2 次）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sell1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;第二次买依赖第一次卖的利润&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LC188（限 k 次）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sell[t-1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;第 t 次买依赖 t-1 次卖的利润&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LC309（冷冻期）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cash_before_yesterday&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;卖出后隔一天才能买&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LC714（手续费）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cash&lt;/code&gt;（同 122）&lt;/td&gt;
&lt;td&gt;卖时扣 &lt;code&gt;fee&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;LC121 - 买卖股票的最佳时机&lt;/h3&gt;
&lt;p&gt;常规解法是维持当前最小值，算所有点的利润。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(self, prices: list[int]) -&amp;gt; int:
    min_price = float(&quot;INF&quot;)
    max_profit = 0
    for price in prices:
        min_price = price if price&amp;lt;min_price else min_price
        max_profit = max(max_profit,price-min_price)
    return max_profit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么为什么放在dp里面呢，我们可以将dp[j]定义为在j下标的时候可以获得最大的利润，然后我们可以在每个位置从头开始循环到j，找到 prices[i] &amp;lt; prices[j] 之后，用转移 dp[j] = dp[i] + (prices[j]-prices[i]) 更新利润。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(prices: list[int]) -&amp;gt; int:
    n = len(prices)
    dp = [0] * n
    for j in range(1,n):
        for i in range(j):
            if prices[i] &amp;lt; prices[j]:
                dp[j] = max(dp[i] + (prices[j]-prices[i]),dp[j])
    return max(dp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过这个法子太朴素所以直接超时了。这里，我们引入通杀股票问题的股票DP，它定义了两个状态，hold 是“如果我今天收盘手里必须有股票，我最多还能剩多少钱”；cash 是“如果我今天收盘手里必须没股票，我最多已经赚多少钱”。其实就是分开算买股票花的钱和卖股票赚的钱，最后去返回cash即可。&lt;/p&gt;
&lt;p&gt;那么，第一天的hold，我赚不了钱，利润只能是 -prices[0]，买入第一天的股票。cash收盘也拿不到现金。&lt;/p&gt;
&lt;p&gt;我们举个最小例子，比如[7, 1, 5]。初始持股 hold = -7，cash = 0。遇到价格1的时候，先算这一天直接卖出，cash = max(0, -7+1) = 0，显然不合算，然后再更新hold， hold = max(-7, -1) = -1，也就是说，这一天买入比前一天买入好，所以更新hold 为 -1 ；遇到5的时候，cash = max(0, -1 + 5) = 4， 说明卖出可得4元，然后再更新 hold = max(-1, -5) = -1，说明还是捏着-1比较好。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(self, prices: list[int]) -&amp;gt; int:
    hold = -prices[0]
    cash = 0

    for price in prices[1:]:
        new_cash = max(cash, hold + price)
        new_hold = max(hold, -price)

        cash = new_cash
        hold = new_hold

    return cash
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC122 - 买卖股票的最佳时机 II&lt;/h3&gt;
&lt;p&gt;买卖股票II允许你在持股一个的情况下，每天都可以多次买卖，最后得到的利润总和要最大。所以，我们在定义的时候，cash可以差不多，都是 cash = max(cash, hold + price)，但是hold不能再简单取最小的买入代价。之前，只能交易一次，如果今天买入，说明之前没有交易，收益为0，所以只能结果是 hold = max(hold, 0-price)；而这一题则是可能有cash了，hold = max(hold, cash - price)。&lt;/p&gt;
&lt;p&gt;只需要改变一行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(self, prices: list[int]) -&amp;gt; int:
    hold = -prices[0]
    cash = 0

    for price in prices[1:]:
        new_cash = max(cash, hold + price)
        new_hold = max(hold, cash - price)

        cash = new_cash
        hold = new_hold

    return cash
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC123 - 买卖股票的最佳时机 III&lt;/h3&gt;
&lt;p&gt;这一题，给定一个数组，它的第 i 个元素是一支给定的股票在第 i 天的价格，且最多只能完成两比交易。所以这一题，我们其实可以看成两个买卖股票I，第二次的buy要承接第一次sell得到的利润，因为不能同时交易，买二必先卖一。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(self, prices: list[int]) -&amp;gt; int:
    buy1 = -prices[0]
    sell1 = 0
    buy2 = -prices[0]
    sell2 = 0

    for price in prices[1:]:
        new_buy1 = max(buy1, -price)
        new_sell1 = max(sell1, buy1 + price)

        new_buy2 = max(buy2, sell1 - price)
        new_sell2 = max(sell2, buy2 + price)

        buy1, sell1, buy2, sell2 = new_buy1, new_sell1, new_buy2, new_sell2

    return sell2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC188 - 买卖股票的最佳时机 IV&lt;/h3&gt;
&lt;p&gt;这一题再次升级，最多重复买卖k次，但是依然不能同时交易。其实就是将LC123的四个状态推广成循环，变成“buy[1], sell[1], buy[2], sell[2], ..., buy[k], sell[k]”。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(self, prices: list[int]) -&amp;gt; int:
    n = len(prices)
    if n == 0 or k == 0:
        return 0

    buy = [-10**18] * (k + 1)
    sell = [0] * (k + 1)

    for t in range(1, k + 1):
        buy[t] = -prices[0]

    for price in prices[1:]:
        new_buy = buy[:]
        new_sell = sell[:]

        for t in range(1, k + 1):
            new_buy[t] = max(buy[t], sell[t - 1] - price)
            new_sell[t] = max(sell[t], buy[t] + price)

        buy, sell = new_buy, new_sell

    return sell[k]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC309 - 买卖股票的最佳时机含冷冻期&lt;/h3&gt;
&lt;p&gt;这一题给股票买卖带来了冷冻期，卖出股票无法第二天买入。所以，我们的新hold必须要是前天的cash来减。要改的只有这里。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(self, prices: list[int]) -&amp;gt; int:
    n = len(prices)
    if n == 0:
        return 0
    hold = -prices[0]
    cash_before_yesterday = 0
    cash = 0

    for price in prices:
        new_cash = max(cash,hold + price)
        new_hold = max(hold,cash_before_yesterday-price)

        cash_before_yesterday = cash
        cash = new_cash
        hold = new_hold
    return cash
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC714 - 买卖股票的最佳时机含手续费&lt;/h3&gt;
&lt;p&gt;这一题和LC122（无限次买卖）相比，只是多了一个手续费，更改一下cash的更新即可解决。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxProfit(self, prices: list[int], fee: int) -&amp;gt; int:
    n = len(prices)
    if n == 0:
        return 0
    hold = -prices[0]
    cash = 0

    for price in prices:
        new_cash = max(cash,hold + price - fee)
        new_hold = max(hold,cash - price)

        cash = new_cash
        hold = new_hold
    return cash
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;股票 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 通用股票模板（LC122 无限次交易）
hold, cash = -prices[0], 0
for price in prices[1:]:
    cash = max(cash, hold + price)      # 卖出
    hold = max(hold, cash - price)      # 买入
return cash

# k 次交易（LC188）
buy = [-inf] * (k+1); sell = [0] * (k+1)
for t in range(1, k+1):
    buy[t] = -prices[0]
for price in prices[1:]:
    for t in range(1, k+1):
        buy[t] = max(buy[t], sell[t-1] - price)
        sell[t] = max(sell[t], buy[t] + price)
return sell[k]
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;区间 DP&lt;/h1&gt;
&lt;h2&gt;区间 DP 的核心理解&lt;/h2&gt;
&lt;p&gt;区间 DP 的状态定义在 &lt;code&gt;[i, j]&lt;/code&gt; 这个区间上，大区间的解通过枚举分割点 &lt;code&gt;k&lt;/code&gt;，由两个小区间合并得到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dp[i][j] = min/max(dp[i][k] + dp[k][j] + cost(i,j,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;遍历顺序&lt;/strong&gt;：按区间长度从小到大（或 &lt;code&gt;i&lt;/code&gt; 倒序 &lt;code&gt;j&lt;/code&gt; 正序），确保小区间先算好。&lt;/p&gt;
&lt;p&gt;两种常见形式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;两端收缩&lt;/strong&gt;：&lt;code&gt;dp[i][j]&lt;/code&gt; 由 &lt;code&gt;dp[i+1][j]&lt;/code&gt; 和 &lt;code&gt;dp[i][j-1]&lt;/code&gt; 转移（回文 DP、石子游戏）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;中间切分&lt;/strong&gt;：枚举 &lt;code&gt;k&lt;/code&gt; 把 &lt;code&gt;[i,j]&lt;/code&gt; 切成两段（戳气球、三角剖分）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;LC486 - 预测赢家&lt;/h3&gt;
&lt;p&gt;预测赢家在dfs中，是标准的对位dfs解法。我们让双方轮流dfs。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def predictTheWinner(self, nums: list[int]) -&amp;gt; bool:
    # dfs返回的是第一视角下，能赢对手多少分
    @cache
    def dfs(left:int,right:int)-&amp;gt;int:
        if left == right:
            return nums[left]
        # 自己加的分 - 对方能多拿的分
        take_left = nums[left] - dfs(left+1,right)
        take_right = nums[right] - dfs(left,right-1)
        return max(take_left,take_right)
    # 先手(玩家1)优势为正数胜利
    return dfs(0,len(nums)-1)&amp;gt;=0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而既然放在这里，那就是使用区间DP的方法来解决。大区间的解，可以通过小区间解决出来。回文 DP（LC5、LC516）其实也算区间 DP 的特例，典型的区间DP是枚举中间切点转移。&lt;/p&gt;
&lt;p&gt;我们可以定义 dp[i][j] 为[i,j]上先手能净胜的分数，那么很容易写出转化方程为 dp[i][j] = nums[i] - dp[i+1][j]（对手拿右边你拿左边）, dp[i][j] = nums[j] - dp[i][j-1]（对手拿左边你拿右边），这两者取最大的。显然，又要i倒序j正序。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def predictTheWinner(self, nums: list[int]) -&amp;gt; bool:
    n = len(nums)
    dp = [[0]*n for _ in range(n)] 

    # 初始化（区间成点初始）
    for i in range(n):
        dp[i][i] = nums[i]

    for i in range(n-2,-1,-1):
        for j in range(i+1,n):
            dp[i][j] = max(nums[i]-dp[i+1][j],nums[j]-dp[i][j-1])
    return dp[0][n-1]&amp;gt;=0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC877 - 石子游戏&lt;/h3&gt;
&lt;p&gt;这一题先手必赢来着，但是我们还是用dp做一下吧。依旧设dp[i][j]为区间[i,j]下Alice能获得的相对优势，对角线初始化一下。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def stoneGame(self, piles: list[int]) -&amp;gt; bool:
    n = len(piles)
    dp = [[0]*n for _ in range(n)]
    for i in range(n):
        dp[i][i] = piles[i]
    
    for i in range(n-2,-1,-1):
        for j in range(i+1,n):
            dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1])
    return dp[0][n-1]&amp;gt;0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC312 - 戳气球&lt;/h3&gt;
&lt;p&gt;如果直接想先去戳哪个气球，很难拆分成子问题。我们将dp[i][j]定义为i、j不戳，中间戳完的收益。显然，区间只剩一个k的时候，左右邻居一定是i和j，得到的收益是nums[i]*nums[k]*nums[j]。&lt;/p&gt;
&lt;p&gt;那这又有啥用呢？当然有用，我们可以继续往里面拆分，总会有一个情况只有短区间，k是i、j里面唯一的气球，这样就可以推到外面的了。所以，我们枚举最后一个戳的气球k拆分成两个子问题，转移方程为：
dp[i][j] = max(dp[i][k] + dp[k][j] + nums[i] * nums[k] * nums[j])。&lt;/p&gt;
&lt;p&gt;然后，我们按照长度从短到长遍历，在固定长度中寻找i、j端点，然后再从其中遍历切割点k，这样就可以做到从里到外覆盖所有情况了。如果你有印象，在最长回文子串的时候我们就这么做过。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxCoins(self, nums: list[int]) -&amp;gt; int:
    # 补充哨兵
    vals = [1] + nums + [1]          
    m = len(vals)
    dp = [[0] * m for _ in range(m)]
    for length in range(2, m):
        for i in range(m - length):  
            j = i + length           
            for k in range(i + 1, j): 
                dp[i][j] = max(
                    dp[i][j],
                    dp[i][k] + dp[k][j] + vals[i] * vals[k] * vals[j]
                )
    return dp[0][m - 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个题当然也可以像之前那样遍历，因为dp[i][k]：k &amp;lt; j，同一次外层 i 循环里 j 是正序的，dp[i][k] 已经算过，而dp[k][j]：k &amp;gt; i，i 倒序，所以 dp[k][j] 在上几轮外层已经算过。只不过这题按照长度更直观，不过还是写一下吧：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxCoins(self, nums: list[int]) -&amp;gt; int:
    vals = [1] + nums + [1]
    m = len(vals)
    dp = [[0] * m for _ in range(m)]

    for i in range(m - 1, -1, -1):
        for j in range(i + 2, m):
            for k in range(i + 1, j):
                dp[i][j] = max(dp[i][j],
                    dp[i][k] + dp[k][j] + vals[i] * vals[k] * vals[j])

    return dp[0][m - 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1039 - 多边形三角剖分的最低得分&lt;/h3&gt;
&lt;p&gt;你有一个凸的 n 边形，其每个顶点都有一个整数值。给定一个整数数组 values ，其中 values[i] 是按 顺时针顺序 第 i 个顶点的值。&lt;/p&gt;
&lt;p&gt;假设将多边形 剖分 为 n - 2 个三角形。对于每个三角形，该三角形的值是顶点标记的乘积，三角剖分的分数是进行三角剖分后所有 n - 2 个三角形的值之和。&lt;/p&gt;
&lt;p&gt;返回 多边形进行三角剖分后可以得到的最低分 。&lt;/p&gt;
&lt;p&gt;我们必须要解释一下这题，不然根本写不好。实际上，这个多边形的三角剖分，可以直接换成选一条(i,j)做底边，然后枚举顶点k组成三角形，最后再把左右两块丢给递归。&lt;/p&gt;
&lt;p&gt;我们定义dp[i][j]是切开后选择的底边，然后枚举k作为第三个顶点，那么有单词收益是values[i]*values[k]*values[j]，然后可以继续dp[i][k]+dp[k][j]，直到j-i是2的时候，不用选了只有一种可能。所以，这一题简直是和戳气球一模一样！&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minScoreTriangulation(self, values: list[int]) -&amp;gt; int:
    n = len(values)
    dp = [[0] * n for _ in range(n)]

    for gap in range(2, n):
        for i in range(n - gap):
            j = i + gap
            dp[i][j] = float(&apos;inf&apos;)
            for k in range(i + 1, j):
                dp[i][j] = min(dp[i][j],
                    dp[i][k] + dp[k][j] + values[i] * values[k] * values[j])

    return dp[0][n - 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;区间 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 按 gap（j-i）遍历
for gap in range(min_gap, n):
    for i in range(n - gap):
        j = i + gap
        for k in range(i + 1, j):    # 枚举分割点
            dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + cost)

# 两端收缩（石子游戏等）
for i in range(n-2, -1, -1):
    for j in range(i+1, n):
        dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用 &lt;code&gt;gap = j - i&lt;/code&gt; 而非 &lt;code&gt;length&lt;/code&gt; 来迭代，可以统一戳气球（开区间）和回文子串（闭区间）的写法，只改 &lt;code&gt;min_gap&lt;/code&gt;（1 是闭区间，2 是开区间）。&lt;/p&gt;
&lt;h1&gt;树形 DP&lt;/h1&gt;
&lt;h2&gt;树形 DP 的核心理解&lt;/h2&gt;
&lt;p&gt;树形 DP 是在树上做 DP——子节点的 DP 值算好后，再算父节点，底层逻辑是后序遍历。和线性 DP 的区别只是：状态依赖的不是 &lt;code&gt;dp[i-1]&lt;/code&gt;，而是 &lt;code&gt;dp[left.child]&lt;/code&gt; 和 &lt;code&gt;dp[right.child]&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;树形 DP 的递归 dfs 返回包含所有必要状态的元组，每个节点根据子节点的返回值计算自己的状态，并更新全局答案。&lt;/p&gt;
&lt;h3&gt;LC543 - 二叉树的直径&lt;/h3&gt;
&lt;p&gt;还是先来复习这一题本来的做法。我们递归让每个结点都有机会做中转点来更新最长直径，然后我们递归的时候为了方便计算直径只返回以这条边为起点的最长长度。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def diameterOfBinaryTree(self, root: TreeNode):
    max_len = 0
    def dfs(node:TreeNode)-&amp;gt;int:
        nonlocal max_len
        if not node:
            return 0
        left_len = dfs(node.left)
        right_len = dfs(node.right)
        # 题目要算的是边数
        max_len = max(left_len + right_len, max_len)
        return max(left_len+1,right_len+1)
    dfs(root)
    return max_len
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那到底是什么是树形dp？其实啊，树形 DP 就是在树上做 DP（划掉）。就是子节点的 DP 值算好了，再算父节点的。底层的逻辑就是后序遍历。然后这种dfs其实就是树形dp的标准写法了（除非你想从叶子到根一个一个填）。这也得益于树本身没有重叠子问题，不需要cache也不需要dp优化。&lt;/p&gt;
&lt;h3&gt;LC124 - 二叉树中的最大路径和&lt;/h3&gt;
&lt;p&gt;和上一题的区别仅仅在于多了个节点值，相信会了上题也是秒杀的。不过我容易犯错，主要是现在val可能有负数了，加上不一定是正收益，所以我们要即使截断负数（后序的话负数只会作为0传上来）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val = 0, left = None, right = None):
        self.val = val
        self.left = left
        self.right = right

def maxPathSum(self, root: TreeNode) -&amp;gt; int:
    max_val = float(&apos;-INF&apos;)
    def dfs(node:TreeNode)-&amp;gt;int:
        nonlocal max_val
        if not node:
            return 0
        left_val = max(dfs(node.left),0)
        right_val = max(dfs(node.right),0)

        max_val = max(left_val+right_val+node.val,max_val)

        return max(left_val,right_val) + node.val
    dfs(root)
    return max_val
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC968 - 监控二叉树&lt;/h3&gt;
&lt;p&gt;给定一个二叉树，我们在树的节点上安装摄像头。节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。计算监控树的所有节点所需的最小摄像头数量。&lt;/p&gt;
&lt;p&gt;这一题是三状态树形dp，被父亲监控、被孩子监控、自己带着监控。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val = 0, left = None, right = None):
        self.val = val
        self.left = left
        self.right = right

def minCameraCover(self, root: TreeNode) -&amp;gt; int:
    def dfs(node):
        if not node:
            return float(&apos;inf&apos;), 0, 0  # 空节点不可能有摄像头

        l_cam, l_by_child, l_by_parent = dfs(node.left)
        r_cam, r_by_child, r_by_parent = dfs(node.right)

        # 放摄像头：左右孩子爱怎样怎样，我已经全覆盖了
        with_cam = 1 + min(l_cam, l_by_child, l_by_parent) + min(r_cam, r_by_child, r_by_parent)

        # 被孩子覆盖：左右孩子至少有一个放摄像头
        by_child = min(l_cam + min(r_cam, r_by_child),r_cam + min(l_cam, l_by_child))

        # 被父节点覆盖：左右孩子不能指望父节点（父被更上层覆盖），只能靠自己或自己孩子
        by_parent = min(l_cam, l_by_child) + min(r_cam, r_by_child)

        return with_cam, by_child, by_parent
    # 根没有父节点
    return min(dfs(root)[:2])
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC337 - 打家劫舍 III&lt;/h3&gt;
&lt;p&gt;打家劫舍III是树形小区，解法也是树形dp。我们让dfs返回这里偷或者不偷的最高收益，每个节点偷或不偷的收益可以用两种转移方程解决，分别是这个偷左右都不偷和这个不偷，左右偷的较大收益。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def rob(self, root: TreeNode) -&amp;gt; int:
    def dfs(node):
        if not node:
            return (0, 0)

        left_rob, left_not_rob = dfs(node.left)
        right_rob, right_not_rob = dfs(node.right)

        rob_cur = node.val + left_not_rob + right_not_rob
        not_rob_cur = max(left_rob, left_not_rob) + max(right_rob, right_not_rob)

        return (rob_cur, not_rob_cur)

    return max(dfs(root))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;树形 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 通用树形 DP 框架
ans = 0
def dfs(node):
    nonlocal ans
    if not node:
        return base_case
    left = dfs(node.left)
    right = dfs(node.right)
    ans = max(ans, left + right)      # 用子节点更新全局答案
    return max(left, right) + 1       # 返回当前节点的 DP 值
return ans

# 状态机树形 DP（如 LC968）
def dfs(node):
    if not node:
        return inf, 0, 0  # 空节点不能有摄像头
    l_cam, l_covered, l_not = dfs(node.left)
    r_cam, r_covered, r_not = dfs(node.right)
    cam = 1 + min(l_cam, l_covered, l_not) + min(r_cam, r_covered, r_not)
    covered = min(l_cam + min(r_cam, r_covered), r_cam + min(l_cam, l_covered))
    not_covered = min(l_cam, l_covered) + min(r_cam, r_covered)
    return cam, covered, not_covered
return min(dfs(root)[:2])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;树形 DP 天然适合递归写法，树没有重叠子问题，不需要 &lt;code&gt;@cache&lt;/code&gt;。&lt;/p&gt;
&lt;h1&gt;状态压缩 DP&lt;/h1&gt;
&lt;h2&gt;状态压缩的核心理解&lt;/h2&gt;
&lt;p&gt;当 n 很小（≤ 20~25）且决策只关心&quot;哪些元素已经被用了&quot;，可以把选中集合压缩成一个二进制整数 mask。mask 的第 i 位是 1 表示第 i 个元素已被选用。&lt;/p&gt;
&lt;p&gt;核心位运算：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;&amp;lt; n&lt;/code&gt;：状态总数（2^n）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 &amp;lt;&amp;lt; i&lt;/code&gt;：只有第 i 位为 1 的面具&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mask &amp;amp; (1 &amp;lt;&amp;lt; i)&lt;/code&gt;：检查第 i 位是否为 1（读）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mask | (1 &amp;lt;&amp;lt; i)&lt;/code&gt;：将第 i 位设为 1（写）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mask.bit_count()&lt;/code&gt;：统计 mask 中有多少个 1&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;LC698 - 划分为 K 个相等的子集&lt;/h3&gt;
&lt;p&gt;还记得之前的做法吗，是桶划分。我们要分成k个非空子集，就要去装k个桶。我们先算出k个桶的容积，然后从最大的数开始dfs，放桶，如果第一个都放不下，就没有放的必要了，如果最后放满了桶就成功了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def canPartitionKSubsets(self, nums: list[int], k: int) -&amp;gt; bool:
    total = sum(nums)

    if total % k != 0:
        return False
    
    target = total // k
    nums.sort(reverse=True)

    if nums[0]&amp;gt;target:
        return False
    
    buckets = [0] * k

    def dfs(index:int)-&amp;gt;bool:
        if index == len(nums):
            return all(bucket == target for bucket in buckets)
        num = nums[index]
        for i in range(k):
            if buckets[i] + num &amp;gt; target:
                continue
            buckets[i] += num
            if dfs(index+1):
                return True
            buckets[i] -= num

            # 放空桶都失败了直接裁剪
            if buckets[i] == 0:
                return False
        return False
    return dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么，这一题既然放在状态压缩dp，是什么意思呢？回溯法虽然容易理解，但是每次要做两种选择，剪枝能让它不爆炸但绝不是最优。最优的方式是用n个数字的“用没用”编码成n位二进制数mask，比如n=5，用mask = 01001表示第0个和第3个数已经被选了。&lt;/p&gt;
&lt;p&gt;此时，用dp[mask]来表示当前正在处理的桶装了多少，就不用维持很多桶了，因为每个桶的容量一样，我们只看当前选择有没有装满，最后只要判断dp[全1]也就是全部都被选了一遍就可以了。（不熟悉位运算的话，mask &amp;amp; (1 &amp;lt;&amp;lt; i)是判断第i位是不是1，mask | (1 &amp;lt;&amp;lt; i)是将第i位变成1）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def canPartitionKSubsets(self, nums: list[int], k: int) -&amp;gt; bool:
    total = sum(nums)
    if total % k != 0:
        return False
    target = total // k

    n = len(nums)
    # dp记录状态为mask下当前桶装了多少
    dp = [-1] * (1 &amp;lt;&amp;lt; n)   
    dp[0] = 0
    # 1&amp;lt;&amp;lt;n就是2^n个状态，即每个数字选或不选
    for mask in range(1 &amp;lt;&amp;lt; n):
        if dp[mask] == -1:  # 不可达，跳过
            continue
        for i in range(n):
            # 检查第i位是不是1
            if mask &amp;amp; (1 &amp;lt;&amp;lt; i):
                continue
            cur = dp[mask] + nums[i]
            if cur &amp;gt; target:
                continue
            # cur == target → cur % target == 0：当前桶刚好满，清零，下一个数字进新桶
            # cur &amp;lt; target → cur % target == cur：当前桶还没满，继续往里装
            dp[mask | (1 &amp;lt;&amp;lt; i)] = cur % target
    return dp[(1 &amp;lt;&amp;lt; n) - 1] == 0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC473 - 火柴拼正方形&lt;/h3&gt;
&lt;p&gt;还是先用传统桶dfs来做一下。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def makesquare(self, matchsticks: list[int]) -&amp;gt; bool:
    total = sum(matchsticks)
    n = len(matchsticks)
    if total % 4 != 0:
        return False
    target = total // 4
    buckets = [0]*4
    matchsticks.sort(reverse=True)
    def dfs(i):
        if i == n:
            return all(bucket == target for bucket in buckets)
        matchstick = matchsticks[i]
        for k in range(4):
            if buckets[k]+matchstick&amp;gt;target:
                continue
            buckets[k] += matchstick
            if dfs(i+1):
                return True
            buckets[k] -= matchstick

            # 如果空桶放入失败，则肯定失败
            if buckets[k] == 0:
                return False
        return False
    return dfs(0) 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;@cache 只在状态完全由函数参数决定时安全。有外部可变变量时，缓存键不包含它，等于把不同状态误判成同一个。所以要先用cache，我们需要将桶也编码进去，但是这样状态数爆炸，还不如mask压缩。&lt;/p&gt;
&lt;p&gt;我们尝试用上一题的mask压缩。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def makesquare(self, matchsticks: list[int]) -&amp;gt; bool:
    total = sum(matchsticks)
    n = len(matchsticks)
    if total % 4 != 0:
        return False
    target = total // 4
    dp = [-1] * (1&amp;lt;&amp;lt;n)
    dp[0] = 0
    for mask in range(1&amp;lt;&amp;lt;n):
        if dp[mask] == -1:
            continue
        for i in range(n):
            if mask &amp;amp; (1&amp;lt;&amp;lt;i):
                continue
            curr = matchsticks[i] + dp[mask]
            if curr &amp;gt; target:
                continue
            dp[mask|(1&amp;lt;&amp;lt;i)] = curr % target
    return dp[(1&amp;lt;&amp;lt;n)-1] == 0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC464 - 我能赢吗&lt;/h3&gt;
&lt;p&gt;两个玩家可以轮流从公共整数池中抽取，不使用重复数字，达到或超过desiredTotal即胜利。&lt;/p&gt;
&lt;p&gt;给定两个整数 maxChoosableInteger （整数池中可选择的最大数）和 desiredTotal（累计和），若先出手的玩家能稳赢则返回 true ，否则返回 false 。假设两位玩家游戏时都表现 最佳 。&lt;/p&gt;
&lt;p&gt;这一题比起维持一个是否选过的数组，更方便的做法是状态压缩，也就是用mask来表示。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def canIWin(self, maxChoosableInteger: int, desiredTotal: int) -&amp;gt; bool:
    if desiredTotal == 0:
        return True
    # 加起来数字都超不过
    if (1 + maxChoosableInteger) * maxChoosableInteger // 2 &amp;lt; desiredTotal:
        return False
    @cache
    def dfs(mask: int, total: int) -&amp;gt; bool:
        # 对面已经把 total 减到 ≤0 了
        if total &amp;lt;= 0:
            return False
        for i in range(1, maxChoosableInteger + 1):
            # 按下标减少1
            bit = 1 &amp;lt;&amp;lt; (i - 1)
            # 用过了
            if mask &amp;amp; bit:
                continue
            # i大于剩下的total，我赢了
            if i &amp;gt;= total:
                return True
            # 我选 i，轮到对手。对手在新局面里输 → 我赢
            if not dfs(mask | bit, total - i):
                return True
        return False
    return dfs(0, desiredTotal)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然也可以迭代用dp数组，但是说实话没必要，所以算了。&lt;/p&gt;
&lt;h3&gt;LC526 - 优美的排列&lt;/h3&gt;
&lt;p&gt;假设有从 1 到 n 的 n 个整数。用这些整数构造一个数组 perm（下标从 1 开始），只要满足下述条件 之一 ，该数组就是一个 优美的排列 ：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;perm[i] 能够被 i 整除&lt;/li&gt;
&lt;li&gt;i 能够被 perm[i] 整除&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;给你一个整数 n ，返回可以构造的 优美排列 的 数量 。&lt;/p&gt;
&lt;p&gt;同样是1-n选一个放在某个下标下，我们可以用压缩工具mask，然后把状态压缩到这个数字中。我们可以将遍历所有位置，如果这个数字能放进当前小标（能被整除或者能整除，取决于大小），就先放进去然后再dfs下一个数字。所以，dfs放mask，并返回满足的数量。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def countArrangement(self, n: int) -&amp;gt; int:
    @cache
    def dfs(mask: int) -&amp;gt; int:
        # 统计填了多少个1了，+1表示目前要填的位置编号
        pos = mask.bit_count() + 1
        if pos &amp;gt; n:
            return 1
        ans = 0
        for num in range(1, n + 1):
            bit = 1 &amp;lt;&amp;lt; (num - 1)
            if mask &amp;amp; bit:
                continue
            if num % pos == 0 or pos % num == 0:
                ans += dfs(mask | bit)
        return ans
    return dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;状态压缩 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 背包式状态压缩（LC698）
dp = [-1] * (1 &amp;lt;&amp;lt; n)
dp[0] = 0
for mask in range(1 &amp;lt;&amp;lt; n):
    if dp[mask] == -1:
        continue
    for i in range(n):
        if mask &amp;amp; (1 &amp;lt;&amp;lt; i):
            continue
        cur = dp[mask] + nums[i]
        if cur &amp;gt; target:
            continue
        dp[mask | (1 &amp;lt;&amp;lt; i)] = cur % target
return dp[(1 &amp;lt;&amp;lt; n) - 1] == 0

# DFS + cache 式状态压缩（LC464）
@cache
def dfs(mask, total):
    for i in range(1, n + 1):
        bit = 1 &amp;lt;&amp;lt; (i - 1)
        if mask &amp;amp; bit:
            continue
        if i &amp;gt;= total:
            return True
        if not dfs(mask | bit, total - i):
            return True
    return False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;状态压缩 DP 的 n 上限约 20~25（2^20 ≈ 1M），超过这个范围必须换方法。&lt;/p&gt;
&lt;h1&gt;计数 DP&lt;/h1&gt;
&lt;h2&gt;计数 DP 的核心理解&lt;/h2&gt;
&lt;p&gt;计数 DP 求的是方案数，而不是最值。核心区别在于初始化：&lt;code&gt;dp[0] = 1&lt;/code&gt;（&quot;什么都不选&quot;算一种方案），而不是 &lt;code&gt;dp[0] = 0&lt;/code&gt;。转移通常是&lt;strong&gt;加法&lt;/strong&gt;而不是 max/min。&lt;/p&gt;
&lt;p&gt;识别信号：题目问&quot;有多少种方法/方案/排列&quot;。&lt;/p&gt;
&lt;h3&gt;LC62 - 不同路径&lt;/h3&gt;
&lt;p&gt;LC62上面也已经解决过了，没什么可以多说的。&lt;/p&gt;
&lt;h3&gt;LC96 - 不同的二叉搜索树&lt;/h3&gt;
&lt;p&gt;实际上，这一题就是求一个卡特兰数。先给个公式法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import math
def numTrees(self, n: int) -&amp;gt; int:
    return math.comb(2*n,n)//(n+1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过，不知道结论的话，还是必须从头计数。我们的做法是枚举根节点的位置，dp[j] = 以 1 为根的方案数 + 以 2 为根的方案数 + ... + 以 j 为根的方案数。根选为i的时候，左边i-1个节点都比i小，右边j-i个节点都比i大，左右独立，方案数为 dp[i-1]*dp[j-i]。计数转移为 dp[j] += dp[i-1] * dp[j-i]，注意&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def numTrees(self, n: int) -&amp;gt; int:
    dp = [0] * (n + 1)
    # 空树
    dp[0] = 1
    for j in range(1, n + 1):
        for i in range(1, j + 1):
            dp[j] += dp[i - 1] * dp[j - i]

    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC91 - 解码方法&lt;/h3&gt;
&lt;p&gt;1对应A，直到26对应Z，进行了编码。现在要你从字符串数字进行有效解码，且存在可能无法解码的字符串。输出解码的总数。&lt;/p&gt;
&lt;p&gt;这一题有点像IP划分，让人联想到dfs的划分问题，实际上，好像也确实可以这么做，只要让dfs的逻辑变成累加就行了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache
def numDecodings(self, s: str) -&amp;gt; int:
    n = len(s)
    def valid(sub: str) -&amp;gt; bool:
        if sub[0] == &apos;0&apos;:
            return False
        return 1 &amp;lt;= int(sub) &amp;lt;= 26

    @cache
    def dfs(i: int) -&amp;gt; int:
        if i == n:
            return 1
        if s[i] == &apos;0&apos;:
            return 0
        # 不用枚举所有end，最多两位
        # 切 1 位，不是前置零一定有效
        ans = dfs(i + 1)
        # 切 2 位
        if i + 1 &amp;lt; n and valid(s[i:i + 2]):
            ans += dfs(i + 2)
        return ans
    return dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC63 - 不同路径 II&lt;/h3&gt;
&lt;p&gt;已做过。&lt;/p&gt;
&lt;h2&gt;计数 DP 常见模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 计数 DP 通用框架
dp = [0] * (target + 1)
dp[0] = 1  # 什么都不选，一种方案
for item in items:
    for j in range(target, item - 1, -1):  # 0-1 背包倒序
        dp[j] += dp[j - item]                # 加法而非 max
return dp[target]
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;记忆化搜索与 DP 转换&lt;/h1&gt;
&lt;h2&gt;自顶向下&lt;/h2&gt;
&lt;p&gt;递归从原问题出发，一路分解到最小子问题（base case），返回时沿途填充缓存。写法直觉、容易调试，适合状态定义还不清晰的探索阶段。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@cache
def dfs(state):
    if is_base_case(state):
        return base_value
    return max(dfs(s1), dfs(s2), ...)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;自底向上&lt;/h2&gt;
&lt;p&gt;从最小的 base case 开始，按依赖顺序（通常是 for 循环）逐格填表，直到原问题的位置。更适合需要空间压缩的场景。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dp = [0] * (n + 1)
dp[0] = base_value
for i in range(1, n + 1):
    dp[i] = max(dp[i-1], dp[i-2] + val)
return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;递归函数如何改成 DP 数组&lt;/h2&gt;
&lt;p&gt;三步转换法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;dfs&lt;/code&gt; 的参数 → &lt;code&gt;dp&lt;/code&gt; 的下标&lt;/strong&gt;：&lt;code&gt;dfs(i, j)&lt;/code&gt; 对应 &lt;code&gt;dp[i][j]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;dfs&lt;/code&gt; 的返回值 → &lt;code&gt;dp&lt;/code&gt; 的值&lt;/strong&gt;：&lt;code&gt;dp[i][j]&lt;/code&gt; 存的就是 dfs 该返回的东西&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;递归方向 → 循环&lt;/strong&gt;：递归是从大到小调用，循环就从小到大填表（依赖方向反过来）&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;dfs(i) = max(dfs(i-1), dfs(i-2) + val)      # 递归
dp[i] = max(dp[i-1], dp[i-2] + val)         # DP
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;什么时候保留记忆化搜索&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;状态空间稀疏&lt;/strong&gt;：很多状态根本不会访问到（如博弈 DP 中的剪枝）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态转移复杂&lt;/strong&gt;：难以确定遍历顺序时，递归 + cache 更安全&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;树形 DP&lt;/strong&gt;：树天然适合递归，不需要显式填表&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;面试/博客写题&lt;/strong&gt;：记忆化搜索通常更短更清晰&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;什么时候必须转 DP：需要空间压缩、必须严格控制内存（n 很大）、需要显式遍历顺序优化。&lt;/p&gt;
&lt;h1&gt;动态规划题目的分类判断&lt;/h1&gt;
&lt;p&gt;拿到一道新题，按以下顺序判断：&lt;/p&gt;
&lt;h2&gt;看题目是否有重复子问题&lt;/h2&gt;
&lt;p&gt;暴力解是否存在对同一状态反复计算？如果每个子问题只会遇到一次（如树的每个节点），递归就够了，不需要 DP。&lt;/p&gt;
&lt;h2&gt;看题目是否求最值、方案数、可行性&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;最值&lt;/strong&gt;→ 转移用 max/min，初始化边界值&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;方案数&lt;/strong&gt;→ 转移用加法，初始化 &lt;code&gt;dp[0]=1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可行性&lt;/strong&gt;→ 转移用 or，初始化 &lt;code&gt;dp[0]=True&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;看状态是否依赖前一个位置&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;dp[i]&lt;/code&gt; 只由 &lt;code&gt;dp[i-1]&lt;/code&gt; 推出 → 一维 DP，可能空间压缩到 O(1)。&lt;/p&gt;
&lt;h2&gt;看状态是否依赖两个序列&lt;/h2&gt;
&lt;p&gt;两个字符串/数组的匹配问题 → &lt;code&gt;dp[i][j]&lt;/code&gt; 双序列 DP（LCS、编辑距离）。&lt;/p&gt;
&lt;h2&gt;看是否是容量选择问题&lt;/h2&gt;
&lt;p&gt;给定容量目标，从候选集中选物品 → 背包 DP。判断是 0-1（每物最多一次）还是完全（每物无限次）来决定正序或倒序。&lt;/p&gt;
&lt;h2&gt;看是否是区间合并问题&lt;/h2&gt;
&lt;p&gt;问题在数组的一段区间上操作，结果由子区间合并 → 区间 DP（戳气球、三角剖分）。&lt;/p&gt;
&lt;h2&gt;看是否可以用状态压缩表示集合&lt;/h2&gt;
&lt;p&gt;n 很小（≤ 20），只需要知道&quot;哪些元素被用了&quot; → 状态压缩 DP（mask 位运算）。&lt;/p&gt;
&lt;h1&gt;动态规划常见模板&lt;/h1&gt;
&lt;h2&gt;一维 DP 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;dp = [0] * n; dp[0] = init
for i in range(1, n):
    dp[i] = max(dp[i-1], dp[i-2] + val[i])
return dp[-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;二维 DP 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;dp = [[0]*n for _ in range(m)]
dp[0][0] = init
for i in range(1, m): dp[i][0] = ...  # 第一列
for j in range(1, n): dp[0][j] = ...  # 第一行
for i in range(1, m):
    for j in range(1, n):
        dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + cost[i][j]
return dp[-1][-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;0-1 背包模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;dp = [0] * (C+1)
for w, v in items:
    for j in range(C, w-1, -1):      # 倒序
        dp[j] = max(dp[j], dp[j-w] + v)
return dp[C]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;完全背包模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;dp = [0] * (C+1)
for coin in coins:
    for j in range(coin, C+1):       # 正序
        dp[j] = min(dp[j], dp[j-coin] + 1)
return dp[C]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;子序列 DP 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
    for j in range(1, n+1):
        if s[i-1] == t[j-1]:
            dp[i][j] = dp[i-1][j-1] + 1
        else:
            dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;区间 DP 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;for gap in range(min_gap, n):
    for i in range(n - gap):
        j = i + gap
        for k in range(i+1, j):
            dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + cost)
return dp[0][n-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;树形 DP 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if not node:
        return base
    l, r = dfs(node.left), dfs(node.right)
    ans = max(ans, combine(l, r, node))
    return max(l, r) + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;状态压缩 DP 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;dp = [-1] * (1 &amp;lt;&amp;lt; n); dp[0] = 0
for mask in range(1 &amp;lt;&amp;lt; n):
    if dp[mask] == -1: continue
    for i in range(n):
        if mask &amp;amp; (1 &amp;lt;&amp;lt; i): continue
        dp[mask | (1 &amp;lt;&amp;lt; i)] = transition(dp[mask], nums[i])
return dp[(1 &amp;lt;&amp;lt; n) - 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;动态规划问题总结&lt;/h1&gt;
&lt;p&gt;DP 的本质就六个字：&lt;strong&gt;拆问题，存结果&lt;/strong&gt;。不论是一维还是二维，递归还是填表，回溯还是 mask，翻来覆去就是在回答三个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;状态是什么&lt;/strong&gt;？—— 用什么变量能唯一描述一个局面？（&lt;code&gt;dp[i]&lt;/code&gt;、&lt;code&gt;dp[i][j]&lt;/code&gt;、&lt;code&gt;dp[mask]&lt;/code&gt;、树节点）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;转移从哪里来&lt;/strong&gt;？—— 当前状态能从哪些前驱状态一步到达？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;答案在哪里&lt;/strong&gt;？—— 最终要返回的是 dp 表的哪个位置？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;DP 没有什么魔力，它就是暴力搜索的&lt;strong&gt;缓存优化版&lt;/strong&gt;。遇到新题，先写出暴力（哪怕是脑子里），找到重复子问题，定义好 dp 含义，剩下的转移、初始化、遍历顺序都可以按模板来。刷够这几十道经典题，分类套路就刻进肌肉记忆了。&lt;/p&gt;
</content:encoded></item><item><title>算法总结-双指针技巧</title><link>https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E5%8F%8C%E6%8C%87%E9%92%88%E6%8A%80%E5%B7%A7/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E5%8F%8C%E6%8C%87%E9%92%88%E6%8A%80%E5%B7%A7/</guid><description>总结汇总一下双指针技巧。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;介绍&lt;/h2&gt;
&lt;p&gt;双指针技巧，是一种处理数组和链表相关问题经常用到的技巧。其中，最常用到的无非两种：左右指针和快慢指针。&lt;/p&gt;
&lt;h2&gt;相向双指针&lt;/h2&gt;
&lt;p&gt;当数组有序，需要从两端逼近的时候，就可以想到相向双指针。核心特征是我们每次根据条件淘汰一侧，并且不回头&lt;/p&gt;
&lt;h3&gt;LC167 - 两数之和II&lt;/h3&gt;
&lt;p&gt;我们假设两个指针的和已经大于target了，如果移动小的那头，那只会更大于，只有移动大的那头才能保证接下来可能出现两数之和。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(nums:list,target:int)-&amp;gt;int:
    left, right = 0,len(nums)-1
    while left&amp;lt;right:
        if nums[left]+nums[right] == target:
            return [left+1,right+1]
        elif nums[left]+nums[right] &amp;lt; target:
            left+=1
        else:
            right-=1
            

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    target = int(input().strip())
    print(solution(nums,target))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC11 - 盛最多水的容器&lt;/h3&gt;
&lt;p&gt;思维方式类似。移动长边一定会减少水量，只有移动短边才可能会增多水量，所以我们要不断移动短边。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(height:list)-&amp;gt;int:
    left, right = 0, len(height)- 1
    max_pool = 0
    while left&amp;lt;right:
        max_pool = max(max_pool,(right - left)*min(height[left],height[right]))
        if height[left]&amp;lt;height[right]:
            left += 1
        else:
            right -= 1 
    return max_pool

if __name__ == &quot;__main__&quot;:
    height = ast.literal_eval(input().strip())
    print(solution(height))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC125 - 验证回文串&lt;/h3&gt;
&lt;p&gt;给一个句子，删除空格转小写拼接后，判断是否回文。思路很简单，注意python处理清洗就行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(s:str)-&amp;gt;bool:
    if not s:
        return True
    left, right = 0, len(s)-1
    while left&amp;lt;right:
        if s[left]!=s[right]:
            return False
        else:
            left+=1
            right-=1
        return True

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    s_list = []
    for c in s:
        if c.isalnum():
            s_list.append(c.lower())

    s_new = &quot;&quot;.join(s_list)
    print(solution(s_new))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC680 - 验证回文串II&lt;/h3&gt;
&lt;p&gt;本题也就是由于允许一次试错，立刻想到回溯一下&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

# 判断i,j之间的是否是回文串
def solution(i:int,j:int,s:str,flag:int)-&amp;gt;bool:
    if i == j:
        return True
    while i&amp;lt;j:
        if s[i]!=s[j] and flag == 0:
            return solution(i+1,j,s,1)or solution(i,j-1,s,1)
        elif s[i]!=s[j] and flag == 1:
            return False
        else:
            i+=1
            j-=1
    return True  

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(0,len(s)-1,s,0))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC344 - 反转字符串&lt;/h3&gt;
&lt;p&gt;除了字符输入处理倒是没什么可注意的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(s: list[str]) -&amp;gt; list[str]:
    left, right = 0, len(s)-1
    while left&amp;lt;right:
        s[left],s[right] = s[right],s[left]
        left += 1
        right -= 1
    return s

if __name__ == &quot;__main__&quot;:
    # 注意这里的输入
    s = [x.strip().strip(&apos;&quot;&apos;).strip(&quot;&apos;&quot;) for x in input().split(&apos;,&apos;)]
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC881 - 救生艇&lt;/h3&gt;
&lt;p&gt;这题就稍微有难度一点了，要用到贪心的思想。我们先把people按体重排好队，能用最多船的理想方案肯定是轻重搭配，后面按照双指针做就行了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(people, limit):
    people.sort()
    left, right = 0, len(people) - 1
    count = 0

    while left &amp;lt;= right:
        if people[left] + people[right] &amp;lt;= limit:
            left += 1
        right -= 1
        count += 1

    return count


if __name__ == &quot;__main__&quot;:
    people = ast.literal_eval(input().strip())
    limit = int(input().strip())
    print(solution(people,limit))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;同向双指针&lt;/h2&gt;
&lt;p&gt;同向双指针，其实就是滑动窗口。我们什么时候去使用它。&lt;/p&gt;
&lt;h3&gt;LC3 - 无重复字符的最长子串&lt;/h3&gt;
&lt;p&gt;我们需要判断窗口的要素，是元素个数，这是一个与位置无关的属性，因此我们首先想到的就是滑动窗口。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(s:str):
    window = {}
    left , right = 0, 0
    max_len = 0
    while right&amp;lt;len(s):
        c = s[right]
        right += 1
        window[c] = window.get(c,0)+1
        while window[c]&amp;gt;1:
            c2 = s[left]
            left += 1
            window[c2] -= 1
        max_len = max(max_len,right-left)
    return max_len

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    print(solution(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC209 - 长度最小的子数组&lt;/h3&gt;
&lt;p&gt;依旧是满足一个条件的连续序列，条件与位置无关，按照标准滑动窗口思路。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(target:int,nums:list):
    total = 0
    left,right = 0, 0
    min_len = float(&apos;inf&apos;)
    while right&amp;lt;len(nums):
        c = nums[right]
        right += 1 
        total += c
        while total&amp;gt;=target:
            # 先判断长度
            min_len = min(min_len,right-left)
            total -= nums[left]
            left += 1
    return min_len if min_len!=float(&apos;inf&apos;) else 0

if __name__ == &quot;__main__&quot;:
    target = int(input().strip())
    nums = ast.literal_eval(input().strip())
    print(solution(target,nums))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC76 - 最小覆盖子串&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(s:str,t:str)-&amp;gt;str:
    window, need = {},{}
    valid = 0
    min_len = float(&apos;inf&apos;)
    ans = &quot;&quot;
    for c in t:
        need[c] = need.get(c,0) + 1
    left, right = 0, 0 
    while right&amp;lt; len(s):
        c = s[right]
        right += 1
        if c in need:
            window[c] = window.get(c,0)+1
            if window[c] == need[c]:
                valid += 1
        while valid == len(need):
            if right - left &amp;lt; min_len:
                ans  = s[left:right]
                min_len = right - left
            c1 = s[left]
            left += 1
            if c1 in need:
                if window[c1] == need[c1]:
                    valid -= 1
                window[c1] -= 1
    return ans

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    t = input().strip()
    print(solution(s,t))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC438 - 找到字符串中所有字母异位词&lt;/h3&gt;
&lt;p&gt;这道题和前面的题目不一样，是固定窗口的滑动窗口题目。注意一下收缩valid的控制只有一种写法（先判断，再扣window）就行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(s:str,p:str)-&amp;gt;list:
    window , need = {}, {}
    left, right =0, 0
    ans = []
    valid = 0
    for c in p:
        need[c] = need.get(c,0) + 1
    while right&amp;lt;len(s):
        c1 = s[right]
        right += 1
        if c1 in need:
            window[c1] = window.get(c1,0) + 1
            if window[c1] == need[c1]:
                valid += 1
        while right - left &amp;gt; len(p):
            c2 = s[left]
            left += 1
            if c2 in need:
                if window[c2] == need[c2]:
                    valid -= 1
                window[c2] -= 1
        if valid == len(need):
            ans.append(left)
    return ans

if __name__ == &quot;__main__&quot;:
    s = input().strip()
    p = input().strip()
    print(solution(s,p))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC567 - 字符串的排列&lt;/h3&gt;
&lt;p&gt;跟上题一样，是固定窗口滑动.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(s1:str,s2:str)-&amp;gt;list:
    window, need = {}, {}
    valid = 0
    for c in s1:
        need[c] = need.get(c,0) + 1
    left, right = 0, 0
    while right&amp;lt;len(s2):
        c1 = s2[right]
        right += 1
        if c1 in need:
            window[c1] = window.get(c1,0) + 1
            if window[c1] == need[c1]:
                valid += 1
        while right - left &amp;gt; len(s1):
            c2 = s2[left]
            left += 1
            if c2 in need:
                if window[c2] == need[c2]:
                    valid -= 1
                window[c2] -= 1
        # 如果包含，就可以返回了
        if valid == len(need):
            return True
    return False

if __name__ == &quot;__main__&quot;:
    s1 = input().strip()
    s2 = input().strip()
    print(solution(s1,s2))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC904 - 水果成篮&lt;/h3&gt;
&lt;p&gt;这一题，实际上是在求，满足窗口里面只有两种数字的最大连续窗口长度。但是本题有一个很关键的点，就是window[c] 回退到0的时候，key还是存在的，这样会扰乱 if not in window这样的语句，所以到0的话我们需要用del删除这个键（之前为什么不用呢，因为前面关心的主要是window和need的技术关系，和valid维持满足要求的字符种类数，而不关心len(window)）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(fruits:list)-&amp;gt;int:
    max_len = 0
    type = 0
    window = {}
    left, right = 0, 0
    while right&amp;lt;len(fruits):
        c1 = fruits[right]
        right += 1
        if c1 not in window:
            type += 1
        window[c1] = window.get(c1,0) + 1
        while type &amp;gt; 2:
            c2 = fruits[left]
            left += 1
            if c2 in window:
                window[c2] -= 1
                if window[c2] == 0:
                    type -= 1
                    # 删除很关键
                    del window[c2]
                
        # 种类正好两种
        max_len = max(max_len,right - left)
    return max_len

if __name__ == &quot;__main__&quot;:
    fruits = ast.literal_eval(input().strip())
    print(solution(fruits))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另外，其实这题不用维持type了，len(window)本身当做指标，可以让代码更简洁容易：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(fruits: list[int]) -&amp;gt; int:
    window = {}
    left = 0
    max_len = 0

    for right, fruit in enumerate(fruits):
        window[fruit] = window.get(fruit, 0) + 1

        while len(window) &amp;gt; 2:
            left_fruit = fruits[left]
            window[left_fruit] -= 1
            if window[left_fruit] == 0:
                del window[left_fruit]
            left += 1

        max_len = max(max_len, right - left + 1)

    return max_len
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1004 - 最大连续1的个数III&lt;/h3&gt;
&lt;p&gt;这一题要是没有最多翻转k个0这事，就可以直接统计了。有了这个条件后，实际上可以转化成滑动窗口来做，用0的数目（也就是k）来当做窗口需要考虑的属性。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(nums:list,k:int)-&amp;gt;int:
    # 统计0、1的数目
    count_1 = 0
    count_0 = 0
    max_len = 0 
    left, right = 0, 0
    while right &amp;lt; len(nums):
        c1 = nums[right]
        right += 1
        if c1 == 1:
            count_1 += 1
        else:
            count_0 += 1
        while count_0 &amp;gt; k:
            c2 = nums[left]
            left += 1
            if c2 == 1:
                count_1 -= 1
            else:
                count_0 -= 1
        max_len = max(max_len,right - left)
    return max_len


if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    k = int(input().strip())
    print(solution(nums,k))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC713 - 乘积小于K的子数组&lt;/h3&gt;
&lt;p&gt;需要注意的地方都写在题目里了。滑动窗口要避免一直缩窗的情况出现。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def solution(nums:list,k:int)-&amp;gt;int:
    # 避免一直推窗
    if k&amp;lt;=1:
        return 0
    # 全都是正数，直接滑动
    curr = 1
    left, right = 0, 0
    count = 0
    while right&amp;lt;len(nums):
        c1 = nums[right]
        right += 1
        curr *= c1
        while curr &amp;gt;= k:
            c2 = nums[left]
            left += 1
            curr //= c2
        # 当右端点固定在 right - 1 时，当前窗口 [left, right) 的乘积 &amp;lt; k 就合格，换句话说就是所有以 right - 1 结尾、起点在 left ... right - 1 的子数组都合法，也就是有right - left个
        count += (right-left)
    return count

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    k = int(input().strip())
    print(solution(nums,k)) 
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;快慢指针&lt;/h2&gt;
&lt;p&gt;快慢指针式链表、数组的经典题目，代表的三类用法，处理环形、做尺子丈量和找出中点。第一种要记住141、142的结论，第二种是倒数第k个节点的无额外空间解，第三个找中点最常用，就是奇偶容易错，建议脑补一下3、4两种节点。&lt;/p&gt;
&lt;h3&gt;LC141 - 环形链表&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 本题也是经典结论，fast走两步，slow走一步，最终如果相遇则有环
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# 这里升级了一下，直接返回头指针+节点列表
def build_linked_list(nums):
    dummy = ListNode()
    cur = dummy
    nodes = []
    for x in nums:
        cur.next = ListNode(x)
        cur = cur.next
        nodes.append(cur)
    return dummy.next, nodes

def solution(head:ListNode)-&amp;gt;bool:
    slow,fast = head,head
    while fast and fast.next:
        fast = fast.next.next
        slow = slow.next
        if fast == slow:
            return True
    return False

if __name__ == &quot;__main__&quot;:
    nums_line = input().strip()
    pos = int(input().strip())

    # 注意这里有个判空逻辑
    nums = list(map(int, nums_line.split(&apos;,&apos;))) if nums_line else []
    head, nodes = build_linked_list(nums)

    if pos != -1 and nodes:
        nodes[-1].next = nodes[pos]

    print(solution(head))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者这样写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def hasCycle(self, head: Optional[ListNode]) -&amp;gt; bool:
    if not head or not head.next:
        return False
    slow = head
    fast = head.next
    while fast != slow:
        if not fast or not fast.next:
            return False
        slow = slow.next
        fast = fast.next.next
    return True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法有点别扭，一般推荐第一种写法。我们做一点推广，这种判环的最简单写法，归根结底，其实就是这样的模版：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slow = start
fast = start # 或者fast先走一格，一旦next就要判断会不会None.next
while 没有到达“无环结束条件”:
    slow = next(slow)
    fast = next(next(fast))
    if slow == fast:
        说明有环
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC142 - 环形链表II&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self,val = 0,next = None):
        self.val = val
        self.next = next

def solution(self,head:ListNode)-&amp;gt;bool:
    fast, slow = head,head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if fast == slow:
            break
    # 无环返回
    if not fast or not fast.next:
        return None
    start = head
    while start != slow:
        start = start.next
        slow = slow.next
    return start
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC876 - 链表的中间结点&lt;/h3&gt;
&lt;p&gt;来到了快慢链表题，这类题目要注意奇偶的情况，本题要求偶数后半或者奇数中点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(head:ListNode)-&amp;gt;bool:
    fast, slow = head,head
    while fast and fast.next:
        fast = fast.next.next
        slow = slow.next
    return slow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拓展：如果是偶数前半呢？答案是让fast的判断范围往前一步，这样就可以早停slow：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(head:ListNode)-&amp;gt;bool:
    fast, slow = head,head
    while fast.next and fast.next.next:
        fast = fast.next.next
        slow = slow.next
    return slow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拓展：部分题目中，我们可能需要删除头结点，这样就必须要让fast和slow从dummy开始，否则操作就会不统一，很别扭。&lt;/p&gt;
&lt;p&gt;那么，带dummy的找中点前一位，偶数前半，操作其实和之前类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dummy = ListNode(0, head)
fast, slow = dummy, dummy
while fast.next and fast.next.next:
    fast = fast.next.next
    slow = slow.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是此时，slow会停在中点前面的一个合适位置。对于奇数，会返回slow中点前一个节点，偶数会返回前半，这是为了方便切半链表做进一步判断。&lt;/p&gt;
&lt;p&gt;同样，如下写法，是找中点，偶数前半：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dummy = ListNode(0, head)
fast, slow = dummy, dummy
while fast and fast.next:
    fast = fast.next.next
    slow = slow.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意，带dummy的两种写法都是偶数前半，区别只是找到中点还是中点前一位。所以，这一题，如果我们写如下的代码也可以通过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def middleNode(self, head: Optional[ListNode]) -&amp;gt; Optional[ListNode]:
        dummy = ListNode(0, head)
        fast, slow = dummy, dummy
        while fast.next and fast.next.next:
            fast = fast.next.next
            slow = slow.next
        return slow.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后还可以补充一句，其实我们可以直接通过fast最终的位置判断奇偶，比如无dummy，while fast and fast.next，如果fast最后停在None，那就是偶数，否则是奇数。其他的跳法脑补一下3、4个结点的情况。&lt;/p&gt;
&lt;h3&gt;LC19 - 删除链表的倒数第N个结点&lt;/h3&gt;
&lt;p&gt;这是拿fast和slow当尺子，测倒数第N个结点的方法，方法是让fast先走N步，然后同步前进。由于可能会删除头结点，因此需要dummy。&lt;/p&gt;
&lt;p&gt;我们需要停在待删除节点的前一位，可以在脑海中想象一下。所以我们要用fast.next来判断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(head:ListNode,n:int)-&amp;gt;bool:
    dummy = ListNode(0,head)
    p,q = dummy,dummy
    for _ in range(n):
        q = q.next
    while q.next:
        p = p.next
        q = q.next
    p.next = p.next.next
    return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC202 - 快乐数&lt;/h3&gt;
&lt;p&gt;这题乍一看和双指针没关系，直接用循环和set解决：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self,val = 0,next = None):
        self.val = val
        self.next = next

def solution(n:int)-&amp;gt;bool:
    # 不断计算，如果遇到出现过的数字，那么就是无限循环
    if n == 0:
        return False
    seen = set()
    seen.add(0)
    total = 0
    while n:
        mod = n%10
        total += mod*mod
        n = int(n/10)
    while total not in seen:
        seen.add(total)
        now = 0
        while total:
            mod = total%10
            now += mod*mod
            total = int(total/10)
        if now == 1:
            return True
        else:
            total = now
    return False

if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    print(solution(n))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是实际上，这题可以抽象成一个Floyd快慢指针题，本质是在“不断跳到下一个状态”的过程中判断有没有环。我们可以写法变成如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def get_next(n: int) -&amp;gt; int:
    total = 0
    while n &amp;gt; 0:
        digit = n % 10
        total += digit * digit
        n //= 10
    return total

def solution(n: int) -&amp;gt; bool:
    slow = n
    fast = get_next(n)

    while fast != 1 and slow != fast:
        slow = get_next(slow)
        fast = get_next(get_next(fast))

    return fast == 1

if __name__ == &quot;__main__&quot;:
    n = int(input().strip())
    print(solution(n))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Floyd快慢指针法的空间复杂度为O(1)，非常巧妙。&lt;/p&gt;
&lt;p&gt;不过我们可以注意到，在这种逻辑判环中，我们不能用fast的位置来判断有没有环，所以我们需要更新一下条件。&lt;/p&gt;
&lt;p&gt;然后额外注意要用 fast != slow 时，要让他们起点不一样，不然直接都不循环了。&lt;/p&gt;
&lt;h3&gt;LC287 - 寻找重复数&lt;/h3&gt;
&lt;p&gt;同样这题利用下标就可以了，不断将1-n搬到对应的0到n-1位置上，如果已经有了就重复。也就是说这题本来的做法是原地哈希：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(nums: list[int]) -&amp;gt; int:
    i = 0
    while i &amp;lt; len(nums):
        # 当前位置已经是正确数了才调整位置
        if nums[i] == i + 1:
            i += 1
            continue

        correct_idx = nums[i] - 1
        if nums[correct_idx] == nums[i]:
            return nums[i]

        nums[i], nums[correct_idx] = nums[correct_idx], nums[i]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;经过前面的几题，你应该知道了这题可以抽象成Floyd环，因为数组长度是 n+1，值域是 [1, n]，说明一定有重复，重复就会导致“指向同一个位置”，于是形成环。得出代码如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(nums: list[int]) -&amp;gt; int:
    slow = nums[0]
    fast = nums[0]

    while True:
        slow = nums[slow]
        fast = nums[nums[fast]]
        if slow == fast:
            break

    slow = nums[0]
    while slow != fast:
        slow = nums[slow]
        fast = nums[fast]

    return slow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这实际上就是把寻找环位置值的代码抽象了出来。它同时满足不修改数组和额外空间O(1)，是比原地哈希更好的方法。&lt;/p&gt;
&lt;h2&gt;读写双指针&lt;/h2&gt;
&lt;p&gt;读写双指针，是用一根遍历，一根更新的技巧。&lt;/p&gt;
&lt;h3&gt;LC26 - 删除有序数组中的重复项&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def removeDuplicates(self, nums: List[int]) -&amp;gt; int:
    # 删除非严格递增的数组nums，删除重复元素，返回唯一元素个数
    # 两个指针解决问题
    if not nums:
        return 0
    slow = 1
    # 快指针
    for fast in range(1,len(nums)):
        if nums[fast]!=nums[fast-1]:
            # 遇到不一样的搬过来
            nums[slow]=nums[fast]
            slow+=1
    # 慢指针的位置就是所有独一无二的元素长度
    return slow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有一点要注意，这里让fast从1开始是为了不让fast-1越界，并且根据往后看了一格，也全部判断到了。fast还是从0开始比较多，比如写成这样（依旧要判断右边越界）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def removeDuplicates(self, nums: List[int]) -&amp;gt; int:
        i,j = 0,0 
        while j&amp;lt;len(nums):
            if j+1&amp;lt;len(nums) and nums[j] == nums[j+1]:
                j+=1
                continue
            nums[i] = nums[j]
            i+=1
            j+=1
        return i
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC27 - 移除元素&lt;/h3&gt;
&lt;p&gt;跟上一题只是判断条件不同，依然是公式写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def removeElement(self, nums: list[int], val: int) -&amp;gt; int:
    p,q = 0,0
    while q&amp;lt;len(nums):
        if nums[q] == val:
            q+=1
            continue
        nums[p] = nums[q]
        p+=1
        q+=1
    return p
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC80 - 删除有序数组中的重复项II&lt;/h3&gt;
&lt;p&gt;由于是有序数组，本题其实只是比LC26多一位判断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def removeDuplicates(self, nums: list[int]) -&amp;gt; int:
    i,j = 0,0 
    while j&amp;lt;len(nums):
        if j+2&amp;lt;len(nums) and nums[j] == nums[j+1] == nums[j+2]:
            j+=1
            continue
        nums[i] = nums[j]
        i+=1
        j+=1
    return i
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC283 - 移动零&lt;/h3&gt;
&lt;p&gt;也就是将所有非0元素保留，然后p继续移动置0即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def moveZeroes(self, nums: list[int]) -&amp;gt; None:
    i,j = 0, 0
    while j&amp;lt;len(nums):
        if nums[j]!= 0:
            nums[i] = nums[j]
            i+=1
        j+=1
    while i&amp;lt;len(nums):
        nums[i] = 0
        i+=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里要注意一个点，非零元素不用在条件里面j+=1，因为j一定要移动的，就直接放最后面统一加，否则会移动两次。要不然，就在条件里面写一个continue，和之前一样。&lt;/p&gt;
&lt;h3&gt;LC75 - 颜色分类&lt;/h3&gt;
&lt;p&gt;上面很多题，都是直接覆盖，因为不会丢信息，比如移动0只会覆盖0，后面补0就行；重复元素更不用说，有序重复时才覆盖，覆盖不会丢数字。但是这一题，不允许提前排序，如果还覆盖来写，就会丢信息，所以我们要改为交换元素而不是覆盖元素！&lt;/p&gt;
&lt;p&gt;另外还有一个要注意的点，由于我们只是交换元素，所以不能让还没检查过的元素被跳过。尤其是遇到2时，从右侧换回来的元素还不知道是什么，可能还需要继续被判断一次（比如0原本在末尾，被换到了中间，还需要再换到开头）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ast

def sortColors(nums: list[int]) -&amp;gt; None:
    # 由于只有三种元素，所以我们遍历将0放前面，2放后面，中间的自然就是1啦
    i,j,k = 0,0,len(nums)-1
    # 注意这里的循环条件有变化！
    while j &amp;lt;= k:
        if nums[j]==0:
            nums[i],nums[j] = nums[j],nums[i]
            i+=1
            j+=1
        elif nums[j]==2:
            nums[k],nums[j] = nums[j],nums[k]
            # 不确定是否还要移动到开头，所以j先不动，等待下次循环再检查是不是0
            k-=1
        else:
            j+=1

if __name__ == &quot;__main__&quot;:
    nums = ast.literal_eval(input().strip())
    sortColors(nums)
    print(nums)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本题是大名鼎鼎的荷兰国旗问题，用双指针划分出三个区域。有几处是需要注意的。可以好好看看。&lt;/p&gt;
&lt;h2&gt;归并型双指针&lt;/h2&gt;
&lt;p&gt;这种题目常见于链表，一边一个指针，不断将符合条件的加入新结构（也可能是原地）。我们往往需要新指针p来指引新位置。&lt;/p&gt;
&lt;h3&gt;LC88 - 合并两个有序数组&lt;/h3&gt;
&lt;p&gt;这一题的关键是原地合并，nums1后面已经天然给出空位，我们可以直接从大到小从后往前填。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def merge(self, nums1: list[int], m: int, nums2: list[int], n: int) -&amp;gt; None:
    p1 = m - 1
    p2 = n - 1
    p = m + n - 1

    while p2 &amp;gt;= 0:
        if p1 &amp;gt;= 0 and nums1[p1] &amp;gt; nums2[p2]:
            nums1[p] = nums1[p1]
            p1 -= 1
        else:
            nums1[p] = nums2[p2]
            p2 -= 1
        p -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;顺便拓展一下，如果nums1后面没有空位，我们就需要新结构，用非常简单的双指针逐位判断即可，注意走完之后要将结构加在后面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def merge(nums1: list[int], nums2: list[int]) -&amp;gt; list[int]:
    i, j = 0, 0
    ans = []

    while i &amp;lt; len(nums1) and j &amp;lt; len(nums2):
        if nums1[i] &amp;lt;= nums2[j]:
            ans.append(nums1[i])
            i += 1
        else:
            ans.append(nums2[j])
            j += 1

    ans.extend(nums1[i:])
    ans.extend(nums2[j:])
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，也可以用递归的方法。其实，归并双指针问题大多数都可以写成递归，这种递归本质还是双指针，只不过把移动指针的过程交给递归调用。（这种传下标的dfs倒是在需要知道所有情况方便回溯的时候常用，这种情况不太常用）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def merge(nums1: list[int], nums2: list[int]) -&amp;gt; list[int]:
    ans = []

    def dfs(i: int, j: int) -&amp;gt; None:
        if i == len(nums1):
            ans.extend(nums2[j:])
            return
        if j == len(nums2):
            ans.extend(nums1[i:])
            return

        if nums1[i] &amp;lt;= nums2[j]:
            ans.append(nums1[i])
            dfs(i + 1, j)
        else:
            ans.append(nums2[j])
            dfs(i, j + 1)

    dfs(0, 0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC21 - 合并两个有序链表&lt;/h3&gt;
&lt;p&gt;非常经典了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self,val = 0, next = None):
        self.val = val
        self.next = next
    
def solution(list1:ListNode,list2:ListNode)-&amp;gt;ListNode:
    dummy = ListNode()
    p = dummy
    while list1 and list2:
        if list1.val &amp;lt;= list2.val:
            p.next = ListNode(list1.val)
            p = p.next
            list1 = list1.next
        else:
            p.next = ListNode(list2.val)
            p = p.next
            list2 = list2.next            

    p.next = list1 if list1 else list2
    return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC392 - 判断子序列&lt;/h3&gt;
&lt;p&gt;这题也是一边一个指针，按照规则推进的题目，严格来说不是归并题，不过也放在这里了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(s:str,t:str)-&amp;gt;bool:
    p, q = 0, 0
    # 如果p能走到最后，说明是成立的
    while p&amp;lt;len(s) and q&amp;lt;len(t):
        if s[p] == t[q]:
            p+=1
        q+=1
    return p == len(s)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC986 - 区间列表的交集&lt;/h3&gt;
&lt;p&gt;这一题，把单元素换成了一个区间，我们注意相交规则和移动规则。一般，我们移动右边界较小的一侧，因为这样还可能构成更多相交。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def intervalIntersection(self, firstList: list[list[int]], secondList: list[list[int]]) -&amp;gt; list[list[int]]:
    p, q = 0, 0
    ans = []

    while p &amp;lt; len(firstList) and q &amp;lt; len(secondList):
        # 交集题常用
        start = max(firstList[p][0], secondList[q][0])
        end = min(firstList[p][1], secondList[q][1])

        if start &amp;lt;= end:
            ans.append([start, end])

        if firstList[p][1] &amp;lt;= secondList[q][1]:
            p += 1
        else:
            q += 1

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;JZ52 - 两个链表的第一个公共节点&lt;/h3&gt;
&lt;p&gt;这题头一次想很晕，实际是公式打法，公共路程法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, x):
        self.val = x
        self.next = None

def getIntersectionNode(self, headA: ListNode, headB: ListNode) -&amp;gt; ListNode:
    p,q = headA,headB
    while p != q:
        p = p.next if p else headB
        q = q.next if q else headA
    return p
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;固定点 + 双指针&lt;/h2&gt;
&lt;p&gt;固定点双指针题，一般是要在变化的范围内不断用双指针解。&lt;/p&gt;
&lt;h3&gt;LC15 - 三数之和&lt;/h3&gt;
&lt;p&gt;这一题注意为了防止重复，确定答案后移动指针要确认不是相同的值，由于排序了所以相同的值在同一段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def threeSum(self, nums: list[int]) -&amp;gt; list[list[int]]:
    nums.sort()
    ans = []
    for i in range(len(nums)-2):
        if i &amp;gt; 0 and nums[i] == nums[i - 1]:
            continue
        left, right = i + 1, len(nums) - 1
        while left &amp;lt; right:
            s = nums[i] + nums[left] + nums[right]
            # 以0为分界决定移动哪个指针
            if s &amp;lt; 0:
                left += 1
            elif s &amp;gt; 0:
                right -= 1
            else:
                ans.append([nums[i], nums[left], nums[right]])
                left += 1
                right -= 1
                while left &amp;lt; right and nums[left] == nums[left - 1]:
                    left += 1
                while left &amp;lt; right and nums[right] == nums[right + 1]:
                    right -= 1
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC16 - 最接近的三数之和&lt;/h3&gt;
&lt;p&gt;与三数之和的区别只在于找到答案的判定，由于本题只要返回最近和甚至不用去重。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def threeSumClosest(self, nums: list[int], target: int) -&amp;gt; int:
    min_diff = float(&apos;inf&apos;)
    ans = 0
    nums.sort()
    for i in range(len(nums)-2):
        j = i + 1
        k = len(nums) - 1
        while j&amp;lt;k:
            total = nums[i] + nums[j] + nums[k]
            diff = abs(target-total)
            if diff &amp;lt; min_diff:
                min_diff = diff
                ans = total
            if total &amp;lt; target:
                j+=1
            elif total &amp;gt; target:
                k-=1 
            else:
                return total
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC18 - 四数之和&lt;/h3&gt;
&lt;p&gt;四数之和实际上就是固定两个位置之后再使用双指针即可，跳过的思路几乎和三数之和一样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def fourSum(self, nums: list[int], target: int) -&amp;gt; list[list[int]]:
    ans = []
    nums.sort()
    for i in range(len(nums)):
        if i&amp;gt;0 and nums[i] == nums[i-1]:
            continue
        for j in range(i+1,len(nums)):
            if j&amp;gt;i+1 and nums[j] == nums[j-1]:
                continue
            p = j + 1
            q = len(nums)-1
            while p&amp;lt;q:
                if nums[i]+nums[j]+nums[p]+nums[q] == target:
                    ans.append([nums[i],nums[j],nums[p],nums[q]])
                    p+=1
                    q-=1
                    while p&amp;lt;q and nums[p] == nums[p-1]:
                        p+=1
                    while p&amp;lt;q and nums[q] == nums[q+1]:
                        q-=1
                elif nums[i]+nums[j]+nums[p]+nums[q] &amp;lt; target:
                    p+=1
                else:
                    q-=1
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;拓展 - N数之和&lt;/h3&gt;
&lt;p&gt;做一点小扩展，已经解决了两数、三数、四数之和，那么N数之和是不是要固定N-1个数字呢。其实，我们可以通过每次固定一个点，让问题降级，从而跌落到我们熟悉的问题上。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# n数之和，start代表从哪个索引开始计算，这是为了方便递归的设计
def NSum(self, nums: list[int], n:int, start:int, target: int) -&amp;gt; list[list[int]]:
    sz = len(nums)
    res = []
    if n&amp;lt;2 or sz&amp;lt;n:
        return res
    # 2Sum问题
    if n == 2:
        lo, hi = start, sz - 1
        while lo&amp;lt;hi:
            total = nums[lo] + nums[hi]
            left, right = nums[lo], nums[hi]
            if total == target:
                res.append([left,right])
                lo += 1
                hi -= 1
                while lo &amp;lt; hi and nums[lo] == left:
                    lo += 1
                while lo &amp;lt; hi and nums[hi] == right:
                    hi -= 1
            elif total&amp;gt;target:
                hi -= 1
            else:
                lo += 1
        return res
    # 否则，开始递归
    else:
        for i in range(start,sz):
            if i&amp;gt;start and nums[i] == nums[i-1]:
                continue
            subs = self.NSum(nums,n-1,i+1,target-nums[i])
            # (n-1)Sum问题加上nums[i]就是nSum
            for sub in subs:
                sub.append(nums[i])
                res.append(sub)
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC611 - 有效三角形的个数&lt;/h3&gt;
&lt;p&gt;这一题可以转化为最小的两个数字的和是否大于第三个数，从而用三数之和变体做，固定最大的数，用双指针看前面的数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def triangleNumber(self, nums: list[int]) -&amp;gt; int:
    nums.sort()
    count = 0
    for k in range(len(nums) - 1, 1, -1):
        i = 0
        j = k - 1
        while i &amp;lt; j:
            if nums[i] + nums[j] &amp;gt; nums[k]:
                # 此时中间的数字全部满足要求
                count += j - i
                j -= 1
            else:
                i += 1

    return count
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;链表双指针技巧&lt;/h2&gt;
&lt;h3&gt;LC160 - 相交链表&lt;/h3&gt;
&lt;p&gt;与两个链表的第一个公共节点一样，不用看了。&lt;/p&gt;
&lt;h3&gt;LC234 - 回文链表&lt;/h3&gt;
&lt;p&gt;回文链表是链表指针操作的好题，综合了找中点和翻转：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self,val = 0,next = None):
        self.val = val
        self.next = next

def solution(self,head:ListNode)-&amp;gt;bool:
    if not head:
        return True
    # 先找中点，偶数要中前
    fast,slow = head, head
    while fast.next and fast.next.next:
        fast = fast.next.next
        slow = slow.next
    # 翻转
    curr = slow.next
    prev = None
    while curr:
        curr.next, prev, curr = prev,curr, curr.next
    
    # 比较
    while prev:
        if prev.val != head.val:
            return False
        prev = prev.next
        head = head.next
    return True
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC143 - 重排链表&lt;/h3&gt;
&lt;p&gt;这一题就是从中间断开，后面翻转，然后合并。本题需要注意的最大问题是，要求原地合并保留原本的head，这样穿针引线可能会绕一点，也要注意保留下一个元素防止丢失。不过，还是推荐直接用如下方法“Z型合并”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def solution(self, head: ListNode) -&amp;gt; None:
    if not head or not head.next:
        return

    # 1. 找中点
    slow, fast = head, head
    while fast.next and fast.next.next:
        slow = slow.next
        fast = fast.next.next

    # 2. 反转后半段
    second = slow.next
    slow.next = None

    prev = None
    curr = second
    while curr:
        nxt = curr.next
        curr.next = prev
        prev = curr
        curr = nxt

    # 3. 交替合并前半段和反转后的后半段
    first = head
    second = prev

    while second:
        tmp1 = first.next
        tmp2 = second.next

        first.next = second
        second.next = tmp1

        first = tmp1
        second = tmp2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC24 - 两两交换链表中的节点&lt;/h3&gt;
&lt;p&gt;本题也是经典题，两种解法，保留下一个节点的循环交换，或者干脆递归。递归的解法比较简洁好想，所以下面直接递归。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def swapPairs(self, head: ListNode) -&amp;gt; ListNode:
    if not head or not head.next:
        return head
    curr = head
    prev = None
    for _ in range(2):
        curr.next, prev, curr = prev,curr, curr.next
    head.next = swapPairs(curr)
    return prev
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC25 - K个一组翻转链表&lt;/h3&gt;
&lt;p&gt;如果采用递归的策略，跟上一题基本就没区别了，注意退出条件是小于K个剩余节点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def reverseKGroup(self, head: ListNode,k:int) -&amp;gt; ListNode:
    # 如果剩余少于k个直接退出
    p = head
    for _ in range(k):
        if not p:
            return head
        p = p.next

    curr = head
    prev = None
    for _ in range(k):
        curr.next, prev, curr = prev,curr, curr.next
    head.next = reverseKGroup(curr,k)
    return prev
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;双指针问题总结&lt;/h2&gt;
&lt;p&gt;好了，我们刷了这么多双指针题目，可以做一个总结了。相向、同向、快慢、归并、读写、固定点、链表技巧…… 简单梳理一下，该在哪些情况下想到这些解法呢？&lt;/p&gt;
&lt;p&gt;双指针不是一种具体代码，而是一类“用两个位置压缩搜索空间”的思想。它的核心不是一定要有两个变量，而是：我们能不能用两个指针维护某种状态，并且每次移动其中一个指针时，都能确定不会错过答案。&lt;/p&gt;
&lt;p&gt;所以判断一道题能不能用双指针，可以先问自己三个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;题目是不是在处理数组、字符串、链表、区间这类线性结构？&lt;/li&gt;
&lt;li&gt;指针移动后，能不能排除一批不可能的情况？&lt;/li&gt;
&lt;li&gt;是否存在某种单调性、顺序性、窗口性质、链表距离关系？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果答案是肯定的，就可以优先往双指针方向想。&lt;/p&gt;
&lt;h3&gt;1. 相向双指针：两端向中间收缩&lt;/h3&gt;
&lt;p&gt;相向双指针最典型的形态是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;left, right = 0, len(nums) - 1
while left &amp;lt; right:
    if 条件满足:
        处理答案
    elif 需要变大:
        left += 1
    else:
        right -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一类题的关键词是：有序、两端、回文、最大面积、两数之和。&lt;/p&gt;
&lt;p&gt;典型题目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LC167 两数之和II&lt;/li&gt;
&lt;li&gt;LC11 盛最多水的容器&lt;/li&gt;
&lt;li&gt;LC125 验证回文串&lt;/li&gt;
&lt;li&gt;LC680 验证回文串II&lt;/li&gt;
&lt;li&gt;LC881 救生艇&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相向双指针的本质是“每次移动一边，都能排除一批情况”。比如有序数组两数之和中，如果当前和太小，移动右指针只会更小或者不变，没意义，所以只能移动左指针让和变大。&lt;/p&gt;
&lt;p&gt;这类题最重要的是想清楚：为什么移动这个指针不会错过答案？&lt;/p&gt;
&lt;h3&gt;2. 同向双指针：滑动窗口&lt;/h3&gt;
&lt;p&gt;同向双指针一般就是滑动窗口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;left, right = 0, 0
while right &amp;lt; len(nums):
    # 右边界进窗口
    window_add(nums[right])
    right += 1

    while 窗口不满足条件:
        # 左边界出窗口
        window_remove(nums[left])
        left += 1

    更新答案
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一类题的关键词是：连续子数组、连续子串、最长、最短、包含、至多、恰好。&lt;/p&gt;
&lt;p&gt;典型题目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LC3 无重复字符的最长子串&lt;/li&gt;
&lt;li&gt;LC209 长度最小的子数组&lt;/li&gt;
&lt;li&gt;LC76 最小覆盖子串&lt;/li&gt;
&lt;li&gt;LC438 找到字符串中所有字母异位词&lt;/li&gt;
&lt;li&gt;LC567 字符串的排列&lt;/li&gt;
&lt;li&gt;LC904 水果成篮&lt;/li&gt;
&lt;li&gt;LC1004 最大连续1的个数III&lt;/li&gt;
&lt;li&gt;LC713 乘积小于K的子数组&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;滑动窗口的本质是“维护一个连续区间的状态”。右指针负责扩大窗口，左指针负责在条件不满足时收缩窗口。&lt;/p&gt;
&lt;p&gt;这里最容易错的是收缩条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;求最长：通常在窗口不合法时收缩，合法后更新最大值。&lt;/li&gt;
&lt;li&gt;求最短：通常在窗口合法时不断收缩，并在收缩前更新最小值。&lt;/li&gt;
&lt;li&gt;固定长度：窗口长度超过目标长度就收缩。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. 快慢指针：速度差、距离差、判环、中点&lt;/h3&gt;
&lt;p&gt;快慢指针常见于链表，但不只适用于链表。只要一个状态能不断跳到下一个状态，就可能用快慢指针。&lt;/p&gt;
&lt;p&gt;常见模板一，判环：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slow, fast = head, head
while fast and fast.next:
    slow = slow.next
    fast = fast.next.next
    if slow == fast:
        return True
return False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见模板二，找环入口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slow, fast = head, head
while fast and fast.next:
    slow = slow.next
    fast = fast.next.next
    if slow == fast:
        break

if not fast or not fast.next:
    return None

p = head
while p != slow:
    p = p.next
    slow = slow.next
return p
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见模板三，找中点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slow, fast = head, head
while fast and fast.next:
    slow = slow.next
    fast = fast.next.next
return slow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;典型题目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LC141 环形链表&lt;/li&gt;
&lt;li&gt;LC142 环形链表II&lt;/li&gt;
&lt;li&gt;LC876 链表的中间结点&lt;/li&gt;
&lt;li&gt;LC19 删除链表的倒数第N个结点&lt;/li&gt;
&lt;li&gt;LC202 快乐数&lt;/li&gt;
&lt;li&gt;LC287 寻找重复数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;快慢指针的本质是“制造速度差或距离差”。环形链表中，快指针每次多走一步，所以如果有环一定会追上慢指针；删除倒数第N个节点中，先让快指针领先N步，本质是在维护固定距离。&lt;/p&gt;
&lt;p&gt;注意，快乐数和寻找重复数虽然看起来不像链表，但都可以抽象成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;状态 x -&amp;gt; 下一个状态 next(x)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要不断跳转会进入环，就能用 Floyd 快慢指针。&lt;/p&gt;
&lt;h3&gt;4. 读写双指针：一个读，一个写&lt;/h3&gt;
&lt;p&gt;读写双指针的模板一般是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;write = 0
for read in range(len(nums)):
    if nums[read] 应该保留:
        nums[write] = nums[read]
        write += 1
return write
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一类题的关键词是：原地删除、原地保留、移动元素、压缩数组。&lt;/p&gt;
&lt;p&gt;典型题目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LC26 删除有序数组中的重复项&lt;/li&gt;
&lt;li&gt;LC27 移除元素&lt;/li&gt;
&lt;li&gt;LC80 删除有序数组中的重复项II&lt;/li&gt;
&lt;li&gt;LC283 移动零&lt;/li&gt;
&lt;li&gt;LC75 颜色分类&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;读写双指针的本质是“读指针扫描旧数组，写指针维护新数组的下一个位置”。它通常适用于可以覆盖旧值的题目。&lt;/p&gt;
&lt;p&gt;但是要注意，覆盖不是万能的。移动零可以覆盖，因为所有非零元素先写到前面，后面统一补零，不会丢失信息。颜色分类不能简单覆盖，因为 0、1、2 都是有效信息，直接写前后会把还没处理的元素覆盖掉，所以要用交换，也就是荷兰国旗三路划分。&lt;/p&gt;
&lt;p&gt;读写双指针最常见的坑是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;read&lt;/code&gt; 每轮都要移动，不要在 &lt;code&gt;if&lt;/code&gt; 里面加一次、外面又加一次。&lt;/li&gt;
&lt;li&gt;覆盖前要判断这个题能不能丢弃被覆盖的旧值。&lt;/li&gt;
&lt;li&gt;有序数组去重可以利用相邻关系，无序数组去重一般不能直接这样做。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5. 归并型双指针：两个序列一起走&lt;/h3&gt;
&lt;p&gt;归并型双指针通常是一边一个指针：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i, j = 0, 0
ans = []
while i &amp;lt; len(a) and j &amp;lt; len(b):
    if a[i] 更应该被处理:
        ans.append(a[i])
        i += 1
    else:
        ans.append(b[j])
        j += 1

ans.extend(a[i:])
ans.extend(b[j:])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一类题的关键词是：两个有序数组、两个链表、两个区间列表、两个字符串匹配。&lt;/p&gt;
&lt;p&gt;典型题目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LC88 合并两个有序数组&lt;/li&gt;
&lt;li&gt;LC21 合并两个有序链表&lt;/li&gt;
&lt;li&gt;LC392 判断子序列&lt;/li&gt;
&lt;li&gt;LC986 区间列表的交集&lt;/li&gt;
&lt;li&gt;JZ52 两个链表的第一个公共节点&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;归并型双指针的本质是“两边各维护一个当前位置，每次根据规则推进一边或两边”。合并有序数组、合并链表是最标准的归并；判断子序列更准确地说是匹配型双指针，但它也是两个序列一起推进。&lt;/p&gt;
&lt;p&gt;这里要特别记住 LC88：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果目标数组后面有空位，优先从后往前填。&lt;/li&gt;
&lt;li&gt;如果没有空位，通常新建数组从前往后合并。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从后往前的原因是：从前往后会覆盖 &lt;code&gt;nums1&lt;/code&gt; 里还没处理的有效元素，而从后往前正好利用尾部空位。&lt;/p&gt;
&lt;h3&gt;6. 固定点 + 双指针：降维处理&lt;/h3&gt;
&lt;p&gt;固定点 + 双指针，一般用于 &lt;code&gt;N数之和&lt;/code&gt; 或类似计数问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nums.sort()
for i in range(len(nums)):
    if i &amp;gt; 0 and nums[i] == nums[i - 1]:
        continue
    left, right = i + 1, len(nums) - 1
    while left &amp;lt; right:
        total = nums[i] + nums[left] + nums[right]
        if total &amp;lt; target:
            left += 1
        elif total &amp;gt; target:
            right -= 1
        else:
            记录答案
            left += 1
            right -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一类题的关键词是：三数之和、四数之和、N数之和、固定一个数、排序后查找。&lt;/p&gt;
&lt;p&gt;典型题目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LC15 三数之和&lt;/li&gt;
&lt;li&gt;LC16 最接近的三数之和&lt;/li&gt;
&lt;li&gt;LC18 四数之和&lt;/li&gt;
&lt;li&gt;LC611 有效三角形的个数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;固定点 + 双指针的本质是“固定一部分变量，把高维问题降成两数问题”。三数之和就是固定一个数，然后在右侧区间里做两数之和；四数之和就是固定两个数，再在剩余区间里做两数之和。&lt;/p&gt;
&lt;p&gt;这一类题最容易错的是去重：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;固定点要去重。&lt;/li&gt;
&lt;li&gt;找到答案后，左右指针也要跳过重复值。&lt;/li&gt;
&lt;li&gt;最接近的三数之和不需要去重，因为只返回一个和，不返回所有组合。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有效三角形个数虽然看起来不是求和等于某个值，但它也是固定最大边，然后在左侧用双指针批量计数。它的关键是单调性：如果 &lt;code&gt;nums[i] + nums[j] &amp;gt; nums[k]&lt;/code&gt;，那么从 &lt;code&gt;i&lt;/code&gt; 到 &lt;code&gt;j - 1&lt;/code&gt; 的左边界都能和 &lt;code&gt;j, k&lt;/code&gt; 组成三角形，所以可以一次加 &lt;code&gt;j - i&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;7. 链表双指针：先断、再反、再接&lt;/h3&gt;
&lt;p&gt;链表题里的双指针不只是移动速度，还经常和“断链、反转、合并”组合出现。&lt;/p&gt;
&lt;p&gt;典型题目：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LC160 相交链表&lt;/li&gt;
&lt;li&gt;LC234 回文链表&lt;/li&gt;
&lt;li&gt;LC143 重排链表&lt;/li&gt;
&lt;li&gt;LC24 两两交换链表中的节点&lt;/li&gt;
&lt;li&gt;LC25 K个一组翻转链表&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;链表题有几个固定意识：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可能操作头节点时，用 &lt;code&gt;dummy&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;改 &lt;code&gt;next&lt;/code&gt; 之前，先保存后续节点。&lt;/li&gt;
&lt;li&gt;切链表时，记得断开，比如 &lt;code&gt;slow.next = None&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;反转链表的核心永远是 &lt;code&gt;curr.next, prev, curr = prev, curr, curr.next&lt;/code&gt;，但面试里为了可读性，也可以拆成 &lt;code&gt;nxt&lt;/code&gt; 三步写。&lt;/li&gt;
&lt;li&gt;合并两个旧链表时，不要新建值节点，通常是移动旧节点。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如重排链表，整体流程就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;找中点 -&amp;gt; 断开 -&amp;gt; 反转后半段 -&amp;gt; 交替合并
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;链表题的难点往往不是算法本身，而是指针改动顺序。只要涉及 &lt;code&gt;next&lt;/code&gt; 重连，就先把后面要用的节点存起来。&lt;/p&gt;
&lt;h3&gt;8. 题型判断表&lt;/h3&gt;
&lt;p&gt;最后给一个简单判断表，方便刷题时快速定位：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;题目特征&lt;/th&gt;
&lt;th&gt;优先想到&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;有序数组，两端比较&lt;/td&gt;
&lt;td&gt;相向双指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;回文、反转字符串&lt;/td&gt;
&lt;td&gt;相向双指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;连续子数组/子串，最长/最短/包含&lt;/td&gt;
&lt;td&gt;滑动窗口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;原地删除、原地保留、移动元素&lt;/td&gt;
&lt;td&gt;读写双指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;链表判环、找中点、倒数第N个&lt;/td&gt;
&lt;td&gt;快慢指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;状态不断跳转并可能成环&lt;/td&gt;
&lt;td&gt;Floyd快慢指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;合并两个有序结构&lt;/td&gt;
&lt;td&gt;归并型双指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;两个字符串按顺序匹配&lt;/td&gt;
&lt;td&gt;匹配型双指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;三数、四数、N数之和&lt;/td&gt;
&lt;td&gt;固定点 + 双指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;链表重排、回文链表&lt;/td&gt;
&lt;td&gt;快慢指针 + 反转 + 合并&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;9. 最后记忆&lt;/h3&gt;
&lt;p&gt;双指针题的关键不是背代码，而是抓住“指针含义”和“不漏答案的移动理由”。&lt;/p&gt;
&lt;p&gt;每次写双指针，可以先在草稿里写清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;left / right / slow / fast / read / write 分别代表什么？
哪个区间已经处理完？
哪个区间还未知？
什么时候移动左指针？
什么时候移动右指针？
移动后会不会漏掉答案？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果这几个问题都能回答出来，代码就基本不会乱。&lt;/p&gt;
&lt;p&gt;我的个人记忆方式是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;两端排除：相向
连续区间：滑窗
一读一写：读写
速度距离：快慢
两个序列：归并
固定降维：N数之和
链表重连：先存 next，再改 next
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>算法总结-搜索与遍历</title><link>https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E6%90%9C%E7%B4%A2%E4%B8%8E%E9%81%8D%E5%8E%86/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E6%90%9C%E7%B4%A2%E4%B8%8E%E9%81%8D%E5%8E%86/</guid><description>总结汇总一下搜索与遍历题型。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;搜索与遍历的核心理解&lt;/h1&gt;
&lt;h2&gt;什么是遍历&lt;/h2&gt;
&lt;p&gt;遍历强调的是“把结构里的节点都走一遍”。树的前中后序、层序遍历，图里的连通块遍历，本质都是按照某种顺序访问节点，并在访问过程中收集信息。&lt;/p&gt;
&lt;h2&gt;什么是搜索&lt;/h2&gt;
&lt;p&gt;搜索强调的是“在状态空间中找答案”。它不一定要访问所有节点，而是根据题目的目标、约束和剪枝条件，尝试从当前状态转移到下一个状态。比如找路径、找最短步数、枚举所有方案、判断是否存在某种状态。&lt;/p&gt;
&lt;h2&gt;遍历和搜索的区别&lt;/h2&gt;
&lt;p&gt;遍历更像“按结构走完”，搜索更像“带目标地尝试”。很多题二者会重叠：DFS/BFS 既可以是遍历方式，也可以是搜索策略。做题时重点不是纠结名字，而是先判断：状态是什么、选择是什么、什么时候停止、是否需要撤销选择。&lt;/p&gt;
&lt;h2&gt;DFS 和 BFS 的区别&lt;/h2&gt;
&lt;p&gt;DFS 是一条路走到底，天然适合递归、回溯、连通性、枚举方案、判断环。BFS 是一层一层扩散，天然适合无权图最短路、最少步数、多源扩散。&lt;/p&gt;
&lt;h2&gt;什么时候想到 DFS&lt;/h2&gt;
&lt;p&gt;看到“所有路径”“所有方案”“能不能到达”“连通块数量”“从当前状态继续往下试”“需要回溯撤销选择”，优先考虑 DFS。&lt;/p&gt;
&lt;h2&gt;什么时候想到 BFS&lt;/h2&gt;
&lt;p&gt;看到“最短路径”“最少步数”“一圈一圈扩散”“同时从多个源头开始”“无权图距离”，优先考虑 BFS。&lt;/p&gt;
&lt;h2&gt;搜索题里的状态、选择、路径、剪枝&lt;/h2&gt;
&lt;p&gt;搜索题可以拆成四个词：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;状态：当前递归或队列里保存的信息，比如坐标、节点、当前字符串、剩余目标。&lt;/li&gt;
&lt;li&gt;选择：从当前状态能走向哪些下一状态。&lt;/li&gt;
&lt;li&gt;路径：从起点走到当前状态经过的内容，回溯题里通常用 &lt;code&gt;path&lt;/code&gt; 维护。&lt;/li&gt;
&lt;li&gt;剪枝：发现当前状态不可能得到答案时提前返回，比如越界、重复访问、超过目标值、前缀不存在。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;visited 的作用&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;visited&lt;/code&gt; 的核心作用是防止重复访问，但要注意它有两种语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;全局 visited：一个节点访问过就不再访问，常见于图遍历、岛屿淹没。&lt;/li&gt;
&lt;li&gt;路径级 visited：只限制当前路径不能重复使用，回溯结束后要撤销，常见于单词搜索、排列。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;能不能原地修改数组，本质上取决于修改后会不会破坏题目的判断语义。比如岛屿面积可以把陆地改成水，但岛屿周长不能把访问过的陆地改成水，否则会把相邻陆地误算成水边。&lt;/p&gt;
&lt;h2&gt;Python 中常用的数据结构&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;栈/递归：DFS。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deque&lt;/code&gt;：BFS 队列。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;set&lt;/code&gt;：去重、visited、快速判断。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dict&lt;/code&gt;：邻接表、映射、Trie 子节点。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;list[list[int]]&lt;/code&gt;：网格、邻接矩阵、动态维护棋盘。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;树的遍历：按结构走完整棵树&lt;/h1&gt;
&lt;h2&gt;二叉树遍历的核心理解&lt;/h2&gt;
&lt;h2&gt;前序遍历&lt;/h2&gt;
&lt;h3&gt;LC144 - 二叉树的前序遍历&lt;/h3&gt;
&lt;p&gt;三种遍历感觉写过一百遍了，肌肉记忆吧&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode):
    ans = []
    def dfs(node):
        if not node:
            return
        ans.append(node.val)
        dfs(node.left)
        dfs(node.right)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;中序遍历&lt;/h2&gt;
&lt;h3&gt;LC94 - 二叉树的中序遍历&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode):
    ans = []
    def dfs(node):
        if not node:
            return
        dfs(node.left)
        ans.append(node.val)
        dfs(node.right)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;后序遍历&lt;/h2&gt;
&lt;h3&gt;LC145 - 二叉树的后序遍历&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode):
    ans = []
    def dfs(node):
        if not node:
            return
        dfs(node.left)
        dfs(node.right)
        ans.append(node.val)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;层序遍历&lt;/h2&gt;
&lt;h3&gt;LC102 - 二叉树的层序遍历&lt;/h3&gt;
&lt;p&gt;依旧肌肉记忆&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode):
    if not root:
        return []
    q = deque([root])
    ans = []
    while q:
        sz = len(q)
        level = []
        for _ in range(sz):
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        ans.append(level)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC107 - 二叉树的层序遍历 II&lt;/h3&gt;
&lt;p&gt;直接返回的时候reverse一下。。&lt;/p&gt;
&lt;h3&gt;LC103 - 二叉树的锯齿形层序遍历&lt;/h3&gt;
&lt;p&gt;用当len(ans)%2 == 0时候为奇数层，偶数层appendleft。其他不用说了。&lt;/p&gt;
&lt;h3&gt;LC199 - 二叉树的右视图&lt;/h3&gt;
&lt;p&gt;每次取队列的-1位置加入答案。就改一行&lt;code&gt;ans.append(level[-1])&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;N 叉树遍历&lt;/h2&gt;
&lt;h3&gt;LC589 - N 叉树的前序遍历&lt;/h3&gt;
&lt;p&gt;稍微推广一下即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode):
    ans = []
    def dfs(node):
        if not node:
            return 
        ans.append(node.val)
        for child in node.children:
            dfs(child)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC590 - N 叉树的后序遍历&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode):
    ans = []
    def dfs(node):
        if not node:
            return 
        for child in node.children:
            dfs(child)
        ans.append(node.val)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC429 - N 叉树的层序遍历&lt;/h3&gt;
&lt;p&gt;几乎跟二叉树没啥变化&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode):
    if not root:
        return []
    q = deque([root])
    ans = []
    while q:
        sz = len(q)
        level = []
        for _ in range(sz):
            node = q.popleft()
            level.append(node.val)
            if node.children:
                for child in node.children:
                    q.append(child)
        ans.append(level)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;树遍历常见模板&lt;/h2&gt;
&lt;p&gt;树的 DFS 模板核心是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if not node:
        return
    # 前序位置
    dfs(node.left)
    # 中序位置
    dfs(node.right)
    # 后序位置
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;树的 BFS 模板核心是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

q = deque([root])
while q:
    for _ in range(len(q)):
        node = q.popleft()
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;DFS 搜索：一条路走到底&lt;/h1&gt;
&lt;h2&gt;DFS 的核心思想&lt;/h2&gt;
&lt;p&gt;DFS 的核心是“沿着一个方向不断深入，走不动再回退”。递归写法里，每一层函数都代表一个状态，函数内部枚举下一步选择。&lt;/p&gt;
&lt;h2&gt;递归 DFS&lt;/h2&gt;
&lt;p&gt;递归 DFS 最重要的是三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;递归入口表示什么状态。&lt;/li&gt;
&lt;li&gt;递归出口什么时候停止。&lt;/li&gt;
&lt;li&gt;当前状态如何转移到下一状态。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;迭代 DFS&lt;/h2&gt;
&lt;p&gt;迭代 DFS 用栈模拟递归。普通遍历可以写，但回溯题里递归通常更自然，因为递归栈天然保存了路径。&lt;/p&gt;
&lt;h2&gt;DFS 中的 visited&lt;/h2&gt;
&lt;p&gt;图和网格 DFS 通常需要 &lt;code&gt;visited&lt;/code&gt; 或原地标记，否则可能在环里来回走。树结构天然没有回边，一般不需要 &lt;code&gt;visited&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;DFS 中的前序位置和后序位置&lt;/h2&gt;
&lt;p&gt;前序位置适合“进入节点时处理”，比如记录路径、标记访问。后序位置适合“处理完子问题后汇总”，比如树形 DP、课程表 DFS 拓扑排序、回溯撤销选择。&lt;/p&gt;
&lt;h2&gt;图的连通性搜索&lt;/h2&gt;
&lt;h3&gt;LC841 - 钥匙和房间&lt;/h3&gt;
&lt;p&gt;我们将每个房间看做一个节点，房间里面的钥匙指向别的房间，相当于一条边。所以这题，其实就是从0号房间开始搜索，看最终能访问到多少个节点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def canVisitAllRooms(self, rooms: list[list[int]]) -&amp;gt; bool:
    # 如果从一个房间出发，能搜到底，全部加入visited，就返回True
    # 由于房间选择可能不同，使用dfs
    n = len(rooms)
    visited = set()
    def dfs(i):
        # 如果已经访问过了，出口
        if i in visited:
            return 
        # 否则，标记，并且遍历下一个情况
        visited.add(i)
        for room in rooms[i]:
            dfs(room)
    dfs(0)
    return len(rooms) == len(visited)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，这一题也可以bfs来做。因为如果要访问完毕所有房间，那么最终路径长度一定是房间长度。所以我们可以bfs到底看看能不能走那么远即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def canVisitAllRooms(self, rooms: list[list[int]]) -&amp;gt; bool:
    n = len(rooms)
    visited = set()
    # 从0开始bfs
    visited.add(0)
    q = deque([0])
    while q:
        curr = q.popleft()
        for key in rooms[curr]:
            # BFS要防止回搜，dfs可以在递归处来防，bfs必须在这里防
            if key not in visited:
                visited.add(key)
                q.append(key)
    return len(visited) == n
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC547 - 省份数量&lt;/h3&gt;
&lt;p&gt;其实就是给一个邻接矩阵，找连通块数量。最常用方法是统计 DFS 启动次数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def findCircleNum(self, isConnected: list[list[int]]) -&amp;gt; int:
    n = len(isConnected)
    visited = set()

    def dfs(city: int) -&amp;gt; None:
        visited.add(city)
        for nxt in range(n):
            if isConnected[city][nxt] == 1 and nxt not in visited:
                dfs(nxt)

    count = 0
    for city in range(n):
        if city not in visited:
            dfs(city)
            count += 1

    return count
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1971 - 寻找图中是否存在路径&lt;/h3&gt;
&lt;p&gt;听名字就知道要dfs了，它问有一个具有 n 个顶点的 双向 图，其中每个顶点标记从 0 到 n - 1（包含 0 和 n - 1）。图中的边用一个二维整数数组 edges 表示，其中 edges[i] = [ui, vi] 表示顶点 ui 和顶点 vi 之间的双向边。 每个顶点对由 最多一条 边连接，并且没有顶点存在与自身相连的边。请你确定是否存在从顶点 source 开始，到顶点 destination 结束的 有效路径 。&lt;/p&gt;
&lt;p&gt;我们直接从source走一次dfs即可，如果遇到目标节点就返回True，否则返回False。&lt;/p&gt;
&lt;p&gt;这一题考一下邻接表的写法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def validPath(self, n: int, edges: list[list[int]], source: int, destination: int) -&amp;gt; bool:
    # 建立邻接表
    graph = [[] for _ in range(n)]
    for edge in edges:
        graph[edge[0]].append(edge[1])
        graph[edge[1]].append(edge[0])

    # 写dfs函数
    visited = set()
    def dfs(node):
        if node in visited:
            return 
        visited.add(node)
        for nxt in graph[node]:
            dfs(nxt)
    dfs(source)
    return destination in visited
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一题只要找到就可以返回了，所以可以更利落一点，让dfs直接担任寻找答案的任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def validPath(self, n: int, edges: list[list[int]], source: int, destination: int) -&amp;gt; bool:
    # 建立邻接表
    graph = [[] for _ in range(n)]
    for edge in edges:
        graph[edge[0]].append(edge[1])
        graph[edge[1]].append(edge[0])

    # 写dfs函数
    visited = set()
    def dfs(node)-&amp;gt;bool:
        if node == destination:
            return True
        if node in visited:
            return False
        visited.add(node)
        for nxt in graph[node]:
            # 只要有一个dfs通了即可
            if dfs(nxt):
                return True
        # 兜底
        return False
    return dfs(source)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;所有路径搜索&lt;/h2&gt;
&lt;h3&gt;LC797 - 所有可能的路径&lt;/h3&gt;
&lt;p&gt;这一题是一道标准的DAG路径所有路径搜索，我们维持一个path，如果到达了终点就将path加入答案。记得path要回溯即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def allPathsSourceTarget(self, graph: list[list[int]]) -&amp;gt; list[list[int]]:
    # 本题已给邻接表
    path = []
    path.append(0)
    ans = []
    n = len(graph)
    def dfs(node):
        if node ==  n-1:
            ans.append(path[:])
        for nxt in graph[node]:
            path.append(nxt)
            dfs(nxt)
            path.pop()
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一版可以写的更优雅一些，每次dfs开始就path加入node，然后再进入递归退出判断。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    ans = []
    path = []
    target = len(graph) - 1
    def dfs(node):
        path.append(node)
        if node == target:
            ans.append(path[:])
        else:
            for nxt in graph[node]:
                dfs(nxt)
        path.pop()
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC113 - 路径总和 II&lt;/h3&gt;
&lt;p&gt;本题只要求根节点到叶子结点的总和，直接在dfs中维持一个total，判断到叶子的时候是否等于target就行。（注意到空节点不一定是到了叶子节点后，所以要特判叶子节点）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def pathSum(self, root: TreeNode, targetSum: int) -&amp;gt; list[list[int]]:
    if not root:
        return []
    ans = []
    path = []
    def dfs(node,total):
        if not node:
            return
        
        path.append(node.val)
        total += node.val

        if not node.left and not node.right and total == targetSum:
            ans.append(path[:])
            # 这里不可以return！因为这种写法这里return就会弹不出当前元素
            # return
        else:
            dfs(node.left,total)
            dfs(node.right,total)
        path.pop()
    dfs(root,0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果在LC上提交这个解法，可以发现非常慢，其实对于这类还有一个小优化，那就是不要传累加和了，直接传剩余和，这样就是100%了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def pathSum(self, root: TreeNode, targetSum: int) -&amp;gt; list[list[int]]:
    ans = []
    path = []

    def dfs(node, remain):
        if not node:
            return

        path.append(node.val)
        remain -= node.val

        if not node.left and not node.right and remain == 0:
            ans.append(path[:])
        else:
            dfs(node.left, remain)
            dfs(node.right, remain)

        path.pop()

    dfs(root, targetSum)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC257 - 二叉树的所有路径&lt;/h3&gt;
&lt;p&gt;简单题，直接dfs到叶子节点就行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val = 0, left = None, right = None):
        self.val = val 
        self.left = left
        self.right = right

def binaryTreePaths(self, root: TreeNode) -&amp;gt; list[str]:
    path = []
    ans = []
    def dfs(node):
        if not node:
            return
        path.append(str(node.val))
        if not node.left and not node.right:
            ans.append(&quot;-&amp;gt;&quot;.join(path))
        else:
            dfs(node.left)
            dfs(node.right)
        path.pop()
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;DFS 常见模板&lt;/h2&gt;
&lt;p&gt;图 DFS：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if node in visited:
        return
    visited.add(node)
    for nxt in graph[node]:
        dfs(nxt)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;回溯 DFS：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(state):
    if 满足答案条件:
        ans.append(path[:])
        return

    for choice in choices:
        if 不合法:
            continue
        path.append(choice)
        dfs(next_state)
        path.pop()
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;BFS 搜索：一层一层扩散&lt;/h1&gt;
&lt;h2&gt;BFS 的核心思想&lt;/h2&gt;
&lt;p&gt;BFS 的核心是“从起点一层一层向外扩散”。队列中的节点按照距离起点由近到远的顺序被处理，所以在无权图中，第一次到达某个状态时就是最短距离。&lt;/p&gt;
&lt;h2&gt;BFS 为什么能求最短路&lt;/h2&gt;
&lt;p&gt;因为 BFS 每一轮只走一步，先处理距离为 &lt;code&gt;k&lt;/code&gt; 的所有状态，再处理距离为 &lt;code&gt;k + 1&lt;/code&gt; 的状态。如果边权都相同，第一次遇到终点时，不可能还有更短路径没被处理。&lt;/p&gt;
&lt;h2&gt;BFS 中的队列和层数&lt;/h2&gt;
&lt;p&gt;常见层数写法是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;step = 0
while q:
    for _ in range(len(q)):
        cur = q.popleft()
        ...
    step += 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以把距离和节点一起入队，比如 &lt;code&gt;q.append((node, dist))&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;BFS 中的 visited&lt;/h2&gt;
&lt;p&gt;BFS 的 &lt;code&gt;visited&lt;/code&gt; 最好在入队时就标记，而不是出队时再标记。这样可以避免同一个状态被多个父节点重复加入队列。&lt;/p&gt;
&lt;h2&gt;无权图最短路&lt;/h2&gt;
&lt;h3&gt;LC752 - 打开转盘锁&lt;/h3&gt;
&lt;p&gt;简单来说，一次操作可以有8种不同的状态转移，询问能不能到达最终状态。但是，这题需要返回最小旋转次数，所以一定是需要bfs一层一层往外数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def openLock(self, deadends: list[str], target: str) -&amp;gt; int:
    def update(state: str) -&amp;gt; list[str]:
        res = []
        chars = list(state)
        for i in range(4):
            old = chars[i]

            chars[i] = str((int(old) + 1) % 10)
            res.append(&quot;&quot;.join(chars))

            chars[i] = str((int(old) + 9) % 10)
            res.append(&quot;&quot;.join(chars))

            chars[i] = old
        return res

    visited = set(deadends)
    if &quot;0000&quot; in visited:
        return -1
    if target == &quot;0000&quot;:
        return 0

    q = deque([&quot;0000&quot;])
    visited.add(&quot;0000&quot;)
    step = 0

    while q:
        for _ in range(len(q)):
            cur = q.popleft()
            if cur == target:
                return step

            for nxt in update(cur):
                if nxt not in visited:
                    visited.add(nxt)
                    q.append(nxt)

        step += 1

    return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意一下层数统计和去重等操作。&lt;/p&gt;
&lt;h3&gt;LC773 - 滑动谜题&lt;/h3&gt;
&lt;p&gt;同样是问多少次，使用广搜，不过注意这里的状态转移可以转化成一维来做。注意去重要tuple。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque


def slidingPuzzle(self, board: list[list[int]]) -&amp;gt; int:
    flat = [x for row in board for x in row]
    target = [1, 2, 3, 4, 5, 0]

    def update(state: list[int]) -&amp;gt; list[list[int]]:
        zero_idx = state.index(0)

        match zero_idx:
            case 0:
                nxts = [1, 3]
            case 1:
                nxts = [0, 2, 4]
            case 2:
                nxts = [1, 5]
            case 3:
                nxts = [0, 4]
            case 4:
                nxts = [1, 3, 5]
            case 5:
                nxts = [2, 4]

        res = []
        for nxt in nxts:
            new_state = state[:]
            new_state[zero_idx], new_state[nxt] = new_state[nxt], new_state[zero_idx]
            res.append(new_state)
        return res

    q = deque([flat])
    visited = {tuple(flat)}
    step = 0

    while q:
        for _ in range(len(q)):
            cur = q.popleft()
            if cur == target:
                return step

            for nxt in update(cur):
                nxt_tuple = tuple(nxt)
                if nxt_tuple not in visited:
                    visited.add(nxt_tuple)
                    q.append(nxt)

        step += 1

    return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1091 - 二进制矩阵中的最短路径&lt;/h3&gt;
&lt;p&gt;需要找畅通路径的长度，所以用 BFS。其实就是一道起点到终点的最短路题目，先定义好 8 个方向即可。注意 visited 更新、判断位置等，多练。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def shortestPathBinaryMatrix(self, grid: list[list[int]]) -&amp;gt; int:
    n = len(grid)
    if not grid or grid[0][0] == 1 or grid[n-1][n-1] == 1:
        return -1
    steps = [(1,0),(1,1),(1,-1),(-1,0),(-1,1),(-1,-1),(0,-1),(0,1)]
    q = deque([(0,0)])
    visited = {(0,0)}
    # 途径的单元格总数，所以不包括起点
    count = 1
    while q:
        sz = len(q)
        for _ in range(sz):
            x,y = q.popleft()
            if (x,y) == (n-1,n-1):
                return count
            for dx,dy in steps:
                nx = x + dx
                ny = y + dy
                if 0&amp;lt;=nx&amp;lt;n and 0&amp;lt;=ny&amp;lt;n and grid[nx][ny] == 0 and (nx,ny) not in visited:
                    visited.add((nx,ny))
                    q.append((nx,ny))
        count += 1
    return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1926 - 迷宫中离入口最近的出口&lt;/h3&gt;
&lt;p&gt;最近的出口，还是最短路径，直接一个bfs。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def nearestExit(self, maze: list[list[str]], entrance: list[int]) -&amp;gt; int:
    if not maze:
        return -1
    directions = [(1,0),(-1,0),(0,-1),(0,1)]
    m = len(maze)
    n = len(maze[0])
    visited = set()
    for x in range(m):
        for y in range(n):
            if maze[x][y] == &quot;+&quot;:
                visited.add((x,y))
    # 开始bfs，只要移动到变边界就成功
    step = 0
    q = deque([(entrance[0],entrance[1])])
    visited.add((entrance[0],entrance[1]))
    while q:
        sz = len(q)
        for _ in range(sz):
            x, y = q.popleft()
            # 注意题目明确说起点不能作为出口
            if step &amp;gt; 0 and (x == 0 or x == m-1 or y == 0 or y == n-1):
                return step
            for dx, dy in directions:
                nx = x + dx
                ny = y + dy
                if 0&amp;lt;=nx&amp;lt;m and 0&amp;lt;=ny&amp;lt;n and (nx,ny) not in visited:
                    visited.add((nx,ny))
                    q.append((nx,ny))
        step += 1
    return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，既然是要遍历，其实可以不用先把墙放进visited，而是在入队判断时候加，像这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def nearestExit(self, maze: list[list[str]], entrance: list[int]) -&amp;gt; int:
    if not maze:
        return -1

    m, n = len(maze), len(maze[0])
    directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]

    q = deque([(entrance[0], entrance[1])])
    visited = {(entrance[0], entrance[1])}
    step = 0

    while q:
        for _ in range(len(q)):
            x, y = q.popleft()

            if step &amp;gt; 0 and (x == 0 or x == m - 1 or y == 0 or y == n - 1):
                return step

            for dx, dy in directions:
                nx, ny = x + dx, y + dy
                if 0 &amp;lt;= nx &amp;lt; m and 0 &amp;lt;= ny &amp;lt; n and maze[nx][ny] == &apos;.&apos; and (nx, ny) not in visited:
                    visited.add((nx, ny))
                    q.append((nx, ny))

        step += 1

    return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;速度上会快点。&lt;/p&gt;
&lt;h2&gt;多源 BFS&lt;/h2&gt;
&lt;h3&gt;LC994 - 腐烂的橘子&lt;/h3&gt;
&lt;p&gt;这一题注意维持一个fresh，这样多源bfs之后才知道是否感染完毕。我们准备一个感染队列，先遍历一遍将感染坐标入队，然后开始一层一层感染。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def orangesRotting(self, grid: list[list[int]]) -&amp;gt; int:
    m = len(grid)
    n = len(grid[0])
    q = deque()
    fresh = 0
    for x in range(m):
        for y in range(n):
            if grid[x][y] == 2:
                q.append((x,y))
            elif grid[x][y] == 1:
                fresh += 1
    # 开始 BFS，检查四个方向有没有新鲜橘子
    directions = [(1,0),(-1,0),(0,1),(0,-1)]
    step = 0
    while q and fresh&amp;gt;0:
        for _ in range(len(q)):
            x,y = q.popleft()
            for dx, dy in directions:
                nx = x + dx
                ny = y + dy
                if 0&amp;lt;=nx&amp;lt;m and 0&amp;lt;=ny&amp;lt;n and grid[nx][ny] == 1:
                    grid[nx][ny] = 2
                    q.append((nx,ny))
                    fresh -= 1
        step += 1
    return step if fresh == 0 else -1     
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以观察发现，这一题和上一题的返回时机不一样。这是因为，上一题找路径，到达某个路径之后，答案就已经出来了，但是感染橘子就算循环中干扰上了，也还在这一分钟内，要等待step+=1。&lt;/p&gt;
&lt;h3&gt;LC542 - 01 矩阵&lt;/h3&gt;
&lt;p&gt;我们定义一个bfs函数，返回step，然后对每一个点调用，这样就能得到新矩阵了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def updateMatrix(self, mat: list[list[int]]) -&amp;gt; list[list[int]]:
    m = len(mat)
    n = len(mat[0])
    # 负责输出周围最近的0的距离
    def bfs(x,y)-&amp;gt;int:
        directions = [(-1,0),(1,0),(0,1),(0,-1)]
        q = deque([(x,y)])
        step = 0
        visited = set()
        visited.add((x,y))
        while q:
            for _ in range(len(q)):
                x,y = q.popleft()
                if mat[x][y] == 0:
                    return step
                for dx,dy in directions:
                    nx = x + dx
                    ny = y + dy
                    if 0&amp;lt;=nx&amp;lt;m and 0&amp;lt;=ny&amp;lt;n and (nx,ny) not in visited:
                        visited.add((nx,ny))
                        q.append((nx,ny))
            step += 1
        return -1
    
    result = [row[:] for row in mat]

    for i in range(m):
        for j in range(n):
            result[i][j] = bfs(i,j)
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从思路上来说没问题，但是这一题这样写铁超时的，因为我们要每次对一个点做一个bfs。多源bfs的要点是，我们将需要bfs的点先一起入队，然后一起处理。所以，我们应该写成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def updateMatrix(self, mat: list[list[int]]) -&amp;gt; list[list[int]]:
    m = len(mat)
    n = len(mat[0])
    q = deque()
    dist = [[-1]*n for _ in range(m)]
    for i in range(m):
        for j in range(n):
            if mat[i][j] == 0:
                dist[i][j] = 0
                q.append((i,j))

    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

    # 多源bfs
    while q:
        x,y = q.popleft()
        for dx,dy in directions:
            nx,ny = x + dx, y + dy
            # dist兼用visited
            if 0&amp;lt;=nx&amp;lt;m and 0&amp;lt;=ny&amp;lt;n and dist[nx][ny] == -1:
                # 向外扩散
                dist[nx][ny] = dist[x][y] + 1
                q.append((nx,ny))
    return dist
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC286 - 墙与门&lt;/h3&gt;
&lt;p&gt;这一题和上一题 &lt;code&gt;LC542 - 01 矩阵&lt;/code&gt; 几乎是同一种味道，都是典型的多源 BFS。&lt;/p&gt;
&lt;p&gt;题目大意如下：&lt;/p&gt;
&lt;p&gt;给定一个 &lt;code&gt;m x n&lt;/code&gt; 的二维网格 &lt;code&gt;rooms&lt;/code&gt;，其中每个位置有三种可能的值：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-1&lt;/code&gt; 表示墙或者障碍物&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0&lt;/code&gt; 表示一扇门&lt;/li&gt;
&lt;li&gt;&lt;code&gt;INF&lt;/code&gt; 表示一个空房间，这里的 &lt;code&gt;INF = 2147483647&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;要求你把每个空房间替换成它到最近门的距离。如果无法走到任何门，那么这个位置保持 &lt;code&gt;INF&lt;/code&gt; 不变。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入：
INF  -1   0  INF
INF INF INF  -1
INF  -1 INF  -1
  0  -1 INF INF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行之后应变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  3  -1   0   1
  2   2   1  -1
  1  -1   2  -1
  0  -1   3   4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这题如果朴素去想，很容易写成“对每个空房间单独做一次 BFS 去找最近的门”，但那样复杂度会很高。更好的方式是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把所有门 &lt;code&gt;0&lt;/code&gt; 一起入队&lt;/li&gt;
&lt;li&gt;同时向外扩散&lt;/li&gt;
&lt;li&gt;第一次更新到某个空房间时，这个距离就是它到最近门的最短距离&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def wallsAndGates(self, rooms: list[list[int]]) -&amp;gt; None:
    if not rooms or not rooms[0]:
        return

    INF = 2147483647
    m, n = len(rooms), len(rooms[0])
    q = deque()

    for i in range(m):
        for j in range(n):
            if rooms[i][j] == 0:
                q.append((i, j))

    directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]

    while q:
        x, y = q.popleft()
        for dx, dy in directions:
            nx, ny = x + dx, y + dy
            if 0 &amp;lt;= nx &amp;lt; m and 0 &amp;lt;= ny &amp;lt; n and rooms[nx][ny] == INF:
                rooms[nx][ny] = rooms[x][y] + 1
                q.append((nx, ny))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;双向 BFS&lt;/h2&gt;
&lt;h3&gt;LC127 - 单词接龙&lt;/h3&gt;
&lt;p&gt;字典 wordList 中从单词 beginWord 到 endWord 的 转换序列 是一个按下述规格形成的序列 beginWord -&amp;gt; s1 -&amp;gt; s2 -&amp;gt; ... -&amp;gt; sk：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每一对相邻的单词只差一个字母。&lt;/li&gt;
&lt;li&gt;对于 1 &amp;lt;= i &amp;lt;= k 时，每个 si 都在 wordList 中。注意， beginWord 不需要在 wordList 中。&lt;/li&gt;
&lt;li&gt;sk == endWord&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;给你两个单词 beginWord 和 endWord 和一个字典 wordList ，返回 从 beginWord 到 endWord 的 最短转换序列 中的 单词数目 。如果不存在这样的转换序列，返回 0 。&lt;/p&gt;
&lt;p&gt;本题也是求到终点的路径长度，当相差一个字母的时候可以转移，我们通过辅助函数来判断能不能转移。&lt;/p&gt;
&lt;p&gt;但是，如果这一题从一头开始搜，因为一个单词可以改26个字母，如果只从一路搜，路数会膨胀得很快。所以我们从两边一起往中间搜，只要遇到了，就存在。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def ladderLength(self, beginWord: str, endWord: str, wordList: list[str]) -&amp;gt; int:
    word_set = set(wordList)
    if endWord not in word_set:
        return 0

    begin_set = {beginWord}
    end_set = {endWord}
    visited = {beginWord, endWord}
    step = 1

    while begin_set and end_set:
        # 始终从更小的一侧扩展，减少分支数
        if len(begin_set) &amp;gt; len(end_set):
            begin_set, end_set = end_set, begin_set

        next_level = set()

        for word in begin_set:
            word_chars = list(word)

            for i in range(len(word_chars)):
                old = word_chars[i]

                for ch in &apos;abcdefghijklmnopqrstuvwxyz&apos;:
                    if ch == old:
                        continue

                    word_chars[i] = ch
                    new_word = &apos;&apos;.join(word_chars)

                    if new_word in end_set:
                        return step + 1

                    if new_word in word_set and new_word not in visited:
                        visited.add(new_word)
                        next_level.add(new_word)

                word_chars[i] = old

        begin_set = next_level
        step += 1

    return 0
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;BFS 常见模板&lt;/h2&gt;
&lt;p&gt;单源 BFS：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

q = deque([start])
visited = {start}
step = 0

while q:
    for _ in range(len(q)):
        cur = q.popleft()
        if cur == target:
            return step
        for nxt in get_next(cur):
            if nxt not in visited:
                visited.add(nxt)
                q.append(nxt)
    step += 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多源 BFS 的差别是：先把所有源点一起入队，并全部标记为已访问。&lt;/p&gt;
&lt;h1&gt;网格搜索：二维数组上的 DFS 和 BFS&lt;/h1&gt;
&lt;h2&gt;网格搜索的核心理解&lt;/h2&gt;
&lt;p&gt;网格搜索可以看成图搜索：每个格子是节点，四个方向或八个方向是边。关键是先统一方向数组，再把越界、障碍、访问过这些条件写清楚。&lt;/p&gt;
&lt;h2&gt;网格 DFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]

def dfs(i, j):
    if not (0 &amp;lt;= i &amp;lt; m and 0 &amp;lt;= j &amp;lt; n):
        return
    if is_blocked(i, j):
        return
    grid[i][j] = VISITED
    for dx, dy in directions:
        dfs(i + dx, j + dy)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;网格 BFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]
q = deque([(start_x, start_y)])
visited = {(start_x, start_y)}

while q:
    x, y = q.popleft()
    for dx, dy in directions:
        nx, ny = x + dx, y + dy
        if 0 &amp;lt;= nx &amp;lt; m and 0 &amp;lt;= ny &amp;lt; n and (nx, ny) not in visited:
            visited.add((nx, ny))
            q.append((nx, ny))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;岛屿类问题&lt;/h2&gt;
&lt;h3&gt;LC200 - 岛屿数量&lt;/h3&gt;
&lt;p&gt;岛屿数量是经典的多源dfs，每次dfs的时候我们将相邻的1全部变为0，二重循环进行dfs，最后统计dfs的次数即可。注意一下dfs的退出条件即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def numIslands(self, grid: list[list[str]]) -&amp;gt; int:
    m = len(grid)
    n = len(grid[0])
    def dfs(i,j):
        if not 0&amp;lt;=i&amp;lt;m or not 0&amp;lt;=j&amp;lt;n or grid[i][j] == &apos;0&apos;:
            return
        grid[i][j] = &apos;0&apos;
        dfs(i+1,j)
        dfs(i-1,j)
        dfs(i,j+1)
        dfs(i,j-1)

    count = 0
    for i in range(m):
        for j in range(n):
            if grid[i][j] == &apos;1&apos;:
                dfs(i,j)
                count += 1
    return count
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC695 - 岛屿的最大面积&lt;/h3&gt;
&lt;p&gt;岛屿的最大面积，我们直接让dfs返回这次遍历的面积即可，也就是返回的时候加上方向的dfs。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxAreaOfIsland(self, grid: list[list[int]]) -&amp;gt; int:
    m = len(grid)
    n = len(grid[0])
    def dfs(i,j):
        if not 0&amp;lt;=i&amp;lt;m or not 0&amp;lt;=j&amp;lt;n or grid[i][j] == 0:
            return 0
        grid[i][j] = 0
        return 1+dfs(i+1,j)+dfs(i-1,j)+dfs(i,j+1)+dfs(i,j-1)

    max_S = 0
    for i in range(m):
        for j in range(n):
            if grid[i][j] == 1:
                S = dfs(i,j)
                max_S = max(S,max_S)
    return max_S
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1254 - 统计封闭岛屿的数目&lt;/h3&gt;
&lt;p&gt;这一题的思路是，我们先遍历边界，与边界相邻的大陆必不可能是被包围的岛，所以直接淹掉。然后，就变成了岛屿数目统计，在水中间的都是被包围的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def closedIsland(self, grid: list[list[int]]) -&amp;gt; int:
        m = len(grid)
        n = len(grid[0])
        def dfs(i,j):
            if not 0&amp;lt;=i&amp;lt;m or not 0&amp;lt;=j&amp;lt;n or grid[i][j] == 1:
                return
            grid[i][j] = 1
            dfs(i+1,j)
            dfs(i-1,j)
            dfs(i,j+1)
            dfs(i,j-1)

        # 加一个淹没边界
        for i in range(m):
            if grid[i][0] == 0:
                dfs(i,0)
            if grid[i][n-1] == 0:
                dfs(i,n-1)
        for j in range(n):
            if grid[0][j] == 0:
                dfs(0,j)
            if grid[m-1][j] == 0:
                dfs(m-1,j)

        count = 0
        for i in range(m):
            for j in range(n):
                if grid[i][j] == 0:
                    dfs(i,j)
                    count += 1
        return count
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1020 - 飞地的数量&lt;/h3&gt;
&lt;p&gt;也是边界淹没，然后统计内部面积就行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def numEnclaves(self, grid: list[list[int]]) -&amp;gt; int:
        m = len(grid)
        n = len(grid[0])
        def dfs(i,j):
            if not 0&amp;lt;=i&amp;lt;m or not 0&amp;lt;=j&amp;lt;n or grid[i][j] == 0:
                return 0
            grid[i][j] = 0
            return 1+dfs(i+1,j)+dfs(i-1,j)+dfs(i,j+1)+dfs(i,j-1)

        # 加一个淹没边界
        for i in range(m):
            dfs(i,0)
            dfs(i,n-1)
        for j in range(n):
            dfs(0,j)
            dfs(m-1,j)

        total = 0
        for i in range(m):
            for j in range(n):
                if grid[i][j] == 1:
                    total += dfs(i,j)     
        return total
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC463 - 岛屿的周长&lt;/h3&gt;
&lt;p&gt;这一题让dfs返回周长贡献，有三种情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;某方向越界，说明是外边界，贡献为1&lt;/li&gt;
&lt;li&gt;某方向临水，说明露出来，贡献也是1&lt;/li&gt;
&lt;li&gt;某方向贴着陆地，贡献就是0了&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;def islandPerimeter(self, grid: list[list[int]]) -&amp;gt; int:
        m = len(grid)
        n = len(grid[0])
        visited = set()
        def dfs(i,j):
            if not 0&amp;lt;=i&amp;lt;m or not 0&amp;lt;=j&amp;lt;n or grid[i][j] == 0:
                return 1
            if (i,j) in visited:
                return 0
            visited.add((i,j))
            return dfs(i+1,j)+dfs(i-1,j)+dfs(i,j+1)+dfs(i,j-1)

        # 只有一块岛屿
        for i in range(m):
            for j in range(n):
                if grid[i][j] == 1:
                    return dfs(i,j) 
        return 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意这一题不能将陆地简单变成水，否则会影响到周长计算，所以我们需要额外的visited统计这里有没有算过，防止回头。&lt;/p&gt;
&lt;h2&gt;边界反向搜索&lt;/h2&gt;
&lt;h3&gt;LC130 - 被围绕的区域&lt;/h3&gt;
&lt;p&gt;这一题就是将被X围绕的所有O变成X。我们可以先看边界来dfs，将O先变成#，然后将剩下的O全变成X，再将#变成O。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solve(self, board: list[list[str]]) -&amp;gt; None:
    if not board or not board[0]:
        return

    m, n = len(board), len(board[0])

    def dfs(i, j):
        if not (0 &amp;lt;= i &amp;lt; m and 0 &amp;lt;= j &amp;lt; n) or board[i][j] != &apos;O&apos;:
            return

        board[i][j] = &apos;#&apos;

        dfs(i + 1, j)
        dfs(i - 1, j)
        dfs(i, j + 1)
        dfs(i, j - 1)

    for i in range(m):
        dfs(i, 0)
        dfs(i, n - 1)

    for j in range(n):
        dfs(0, j)
        dfs(m - 1, j)

    for i in range(m):
        for j in range(n):
            if board[i][j] == &apos;O&apos;:
                board[i][j] = &apos;X&apos;
            elif board[i][j] == &apos;#&apos;:
                board[i][j] = &apos;O&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC417 - 太平洋大西洋水流问题&lt;/h3&gt;
&lt;p&gt;如果每个格子 DFS 两次来判断会比较麻烦，我们可以分别从太平洋和大西洋边界区 DFS，然后每次往高处爬，将能够到的位置全都存入各自的 set 中，最后判断同时在两个 set 中的位置。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def pacificAtlantic(self, heights: list[list[int]]) -&amp;gt; list[list[int]]:
    m, n = len(heights), len(heights[0])
    
    pacific = set()
    atlantic = set()

    def dfs(i, j, visited, prev_height):
        if not (0 &amp;lt;= i &amp;lt; m and 0 &amp;lt;= j &amp;lt; n):
            return
        if (i, j) in visited:
            return
        if heights[i][j] &amp;lt; prev_height:
            return

        visited.add((i, j))

        dfs(i + 1, j, visited, heights[i][j])
        dfs(i - 1, j, visited, heights[i][j])
        dfs(i, j + 1, visited, heights[i][j])
        dfs(i, j - 1, visited, heights[i][j])

    for i in range(m):
        dfs(i, 0, pacific, heights[i][0])
        dfs(i, n - 1, atlantic, heights[i][n - 1])

    for j in range(n):
        dfs(0, j, pacific, heights[0][j])
        dfs(m - 1, j, atlantic, heights[m - 1][j])

    ans = []
    for i in range(m):
        for j in range(n):
            if (i, j) in pacific and (i, j) in atlantic:
                ans.append([i, j])

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;单词与路径搜索&lt;/h2&gt;
&lt;h3&gt;LC79 - 单词搜索&lt;/h3&gt;
&lt;p&gt;二重循环枚举每个格子作为起点，如果该格子等于单词首字母，就从这里开始 DFS。DFS 中用 pos 表示当前匹配到 word[pos]，用 visited 防止当前路径中重复使用同一个格子。若 pos == len(word)，说明整个单词匹配成功，返回 True。每次递归结束后需要回溯，将当前格子从 visited 中移除；如果所有起点都无法匹配，则返回 False。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def exist(self, board: list[list[str]], word: str) -&amp;gt; bool:
    m, n = len(board), len(board[0])
    visited = set()

    def dfs(i, j, pos):
        if pos == len(word):
            return True

        if not (0 &amp;lt;= i &amp;lt; m and 0 &amp;lt;= j &amp;lt; n):
            return False

        if (i, j) in visited:
            return False

        if board[i][j] != word[pos]:
            return False

        visited.add((i, j))

        found = (
            dfs(i + 1, j, pos + 1)
            or dfs(i - 1, j, pos + 1)
            or dfs(i, j + 1, pos + 1)
            or dfs(i, j - 1, pos + 1)
        )

        visited.remove((i, j))

        return found

    for i in range(m):
        for j in range(n):
            if board[i][j] == word[0] and dfs(i, j, 0):
                return True

    return False

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意这里是路径级别的visited而不是全局，所以每个dfs中会进行回溯。&lt;/p&gt;
&lt;h3&gt;LC212 - 单词搜索 II&lt;/h3&gt;
&lt;p&gt;如果这一题按照n次单词搜索来做就会超时，因为当words很多的时候，重复搜索会非常严重。这一题实际上是标准的Trie字典树+DFS回溯。&lt;/p&gt;
&lt;p&gt;Trie树是一种用空间换时间的结构，如果你还记得，hot100中有一题就是让构建Trie树。构建好Trie树之后，这一题就是从每个点开始往下搜，搜到了就append答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def findWords(self, board: list[list[str]], words: list[str]) -&amp;gt; list[str]:
    class TrieNode:
        def __init__(self):
            self.children = {}
            self.word = None
        
    class Trie:
        def __init__(self):
            self.root = TrieNode()

        def insert(self,word:str):
            node = self.root
            for ch in word:
                if ch not in node.children:
                    node.children[ch] = TrieNode()
                node = node.children[ch]
            node.word = word

    # 初始化Trie树
    T = Trie()
    for word in words:
        T.insert(word)
    
    # 开始检查，从i、j开始，能不能找到单词
    m = len(board)
    n = len(board[0])

    ans = []

    def dfs(i, j, node):
        if not (0 &amp;lt;= i &amp;lt; m and 0 &amp;lt;= j &amp;lt; n):
            return

        ch = board[i][j]

        # 遍历过或者不在后续
        if ch == &quot;#&quot; or ch not in node.children:
            return

        nxt = node.children[ch]
        # 如果找到
        if nxt.word is not None:
            ans.append(nxt.word)
            nxt.word = None

        # 防止回头
        board[i][j] = &quot;#&quot;

        dfs(i + 1, j, nxt)
        dfs(i - 1, j, nxt)
        dfs(i, j + 1, nxt)
        dfs(i, j - 1, nxt)

        # 路径级回溯
        board[i][j] = ch

    for i in range(m):
        for j in range(n):
            dfs(i, j, T.root)

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;网格搜索常见坑点&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;grid&lt;/code&gt; 里到底是字符串 &lt;code&gt;&apos;1&apos;&lt;/code&gt; 还是整数 &lt;code&gt;1&lt;/code&gt;，要看题目类型。&lt;/li&gt;
&lt;li&gt;DFS 能不能原地修改，取决于修改后会不会影响后续判断。&lt;/li&gt;
&lt;li&gt;BFS 的 visited 尽量入队时标记。&lt;/li&gt;
&lt;li&gt;求最短路优先 BFS，求连通块数量/面积优先 DFS。&lt;/li&gt;
&lt;li&gt;边界反向搜索常用于“被边界影响的区域不算答案”的题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;回溯搜索：枚举所有选择&lt;/h1&gt;
&lt;h2&gt;回溯的核心思想&lt;/h2&gt;
&lt;p&gt;回溯就是 DFS 枚举选择，并在递归返回后撤销选择。它适合解决“所有方案”“所有组合”“所有排列”“所有切割方式”这类题。&lt;/p&gt;
&lt;h2&gt;回溯和 DFS 的关系&lt;/h2&gt;
&lt;p&gt;回溯是 DFS 的一种特殊写法。普通 DFS 只关心能不能走到；回溯还要维护路径，并在离开当前选择时恢复现场。&lt;/p&gt;
&lt;h2&gt;path、choice、used 的含义&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;path&lt;/code&gt; 表示当前已经构造出的方案，&lt;code&gt;choice&lt;/code&gt; 表示这一层可选的候选项，&lt;code&gt;used&lt;/code&gt; 或 &lt;code&gt;visited&lt;/code&gt; 表示哪些元素在当前路径中已经被使用。&lt;/p&gt;
&lt;h2&gt;子集问题&lt;/h2&gt;
&lt;h3&gt;LC78 - 子集&lt;/h3&gt;
&lt;p&gt;经典子集问题，默写级别。两种递归，要么递归 i 选不选，要么递归从 start 开始往下枚举选谁，首先是 start 模板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def subsets(self, nums: list[int]) -&amp;gt; list[list[int]]:
    path = []
    ans = []
    def dfs(start:int):
        ans.append(path[:])
        for i in range(start,len(nums)):
            path.append(nums[i])
            dfs(i+1)
            path.pop()
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 for 循环负责枚举“下一个要选的元素”；而“不再继续选”的情况，由进入 DFS 后立刻 ans.append(path[:]) 表示。所以不用你手动维护不选。&lt;/p&gt;
&lt;p&gt;我们再来看看位置的思路，更加自然，但是通用性降低，也就是直接遍历候选，看看选不选。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def subsets(self, nums: list[int]) -&amp;gt; list[list[int]]:
    path = []
    ans = []
    def dfs(i):
        if i == len(nums):
            ans.append(path[:])
            return
        path.append(nums[i])
        dfs(i+1)
        path.pop()
        dfs(i+1)
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC90 - 子集 II&lt;/h3&gt;
&lt;p&gt;先sort一下，然后重复元素跳过就行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def subsetsWithDup(self, nums: list[int]) -&amp;gt; list[list[int]]:
    nums.sort()
    ans = []
    path = []
    def dfs(start):
        ans.append(path[:])
        # 跳过重复元素
        for i in range(start,len(nums)):
            if i&amp;gt;start and nums[i-1] == nums[i]:
                continue
            path.append(nums[i])
            dfs(i+1)
            path.pop()
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;组合问题&lt;/h2&gt;
&lt;h3&gt;LC77 - 组合&lt;/h3&gt;
&lt;p&gt;组合问题，要求返回范围 [1, n] 中所有可能的 k 个数的组合，不能复选。其实判断加入ans的条件就是 len(path) == k，其余的和子集差不多。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def combine(self, n: int, k: int) -&amp;gt; List[List[int]]:
    ans = []
    path = []
    def dfs(start):
        if len(path) == k:
            ans.append(path[:])
            return
        for i in range(start,n+1):
            path.append(i)
            dfs(i+1)
            path.pop()
    dfs(1)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC39 - 组合总和&lt;/h3&gt;
&lt;p&gt;找出数字和为目标数target的组合，而且可以重复选，而且candidates无重复元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def combinationSum(self, candidates: List[int], target: int) -&amp;gt; List[List[int]]:
    ans = []
    path = []
    def dfs(start,target):
        if target&amp;lt;0:
            return
        if target == 0:
            ans.append(path[:])
            return
        for i in range(start,len(candidates)):
            path.append(candidates[i])
            # 可复选，不用i+1
            dfs(i,target - candidates[i])
            path.pop()
    dfs(0,target)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC40 - 组合总和 II&lt;/h3&gt;
&lt;p&gt;与上一题的区别在于每个数字只能使用一次+组合不允许重复，且candidates中可有重复元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def combinationSum2(self, candidates: List[int], target: int) -&amp;gt; List[List[int]]:
    candidates.sort()
    ans = []
    path = []
    def dfs(start,target):
        if target&amp;lt;0:
            return
        if target == 0:
            ans.append(path[:])
            return
        
        for i in range(start,len(candidates)):
            if i&amp;gt;start and candidates[i-1] == candidates[i]:
                continue
            path.append(candidates[i])
            dfs(i+1,target-candidates[i])
            path.pop()
    dfs(0,target)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC216 - 组合总和 III&lt;/h3&gt;
&lt;p&gt;找出所有相加之和为 n 的 k 个数的组合，且满足下列条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只使用数字1到9&lt;/li&gt;
&lt;li&gt;每个数字 最多使用一次&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;且不能重复组合。其实就是上一题的简化版，候选变成了1-9。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def combinationSum3(self, k: int, n: int) -&amp;gt; List[List[int]]:
    ans = []
    path = []
    def dfs(start,target):
        if target &amp;lt; 0:
            return
        if target == 0 and len(path) == k:
            ans.append(path[:])
            return
        for i in range(start,10):
            path.append(i)
            dfs(i+1,target-i)
            path.pop()
    dfs(1,n)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;排列问题&lt;/h2&gt;
&lt;h3&gt;LC46 - 全排列&lt;/h3&gt;
&lt;p&gt;排列问题中，我们每次都要重头开始数，因为可能后面排在前面。因此，我们还要维护一个数组，来表示对应下标有没有被使用过。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def permute(self, nums: List[int]) -&amp;gt; List[List[int]]:
    n = len(nums)
    seen = [False]*n
    path = []
    ans = []
    def dfs():
        if len(path) == n:
            ans.append(path[:])
            return
        for i in range(n):
            if seen[i]:
                continue
            seen[i] = True
            path.append(nums[i])
            dfs()
            path.pop()
            seen[i] = False
    dfs()
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC47 - 全排列 II&lt;/h3&gt;
&lt;p&gt;给定一个可包含重复数字的序列 nums ，按任意顺序 返回所有不重复的全排列。&lt;/p&gt;
&lt;p&gt;其实跟刚才是一样的，就是多了个重复元素，排序后跳过。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def permuteUnique(self, nums: List[int]) -&amp;gt; List[List[int]]:
    nums.sort()
    n = len(nums)
    seen = [False]*n
    path = []
    ans = []
    def dfs():
        if len(path) == n:
            ans.append(path[:])
            return
        for i in range(n):
            if seen[i]:
                continue
            # 多了一个判断，如果之前没用，现在也不准用，也就是说不允许有顺序区分
            if i&amp;gt;0 and nums[i] == nums[i-1] and not seen[i-1]:
                continue
            seen[i] = True
            path.append(nums[i])
            dfs()
            path.pop()
            seen[i] = False
    dfs()
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;切割问题&lt;/h2&gt;
&lt;h3&gt;LC131 - 分割回文串&lt;/h3&gt;
&lt;p&gt;一维分割问题其实就是每次找到一种方案之后再递归下一个位置分割，直到分割完毕保存答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def partition(self, s: str) -&amp;gt; List[List[str]]:
    def palindrome(string:str):
        i, j = 0, len(string)-1
        while i&amp;lt;j:
            if string[i]!=string[j]:
                return False
            i += 1
            j -= 1
        return True
    
    ans = []
    path = []

    def dfs(start):
        if start == len(s):
            ans.append(path[:])
            return
        for i in range(start,len(s)):
            curr = s[start:i+1]
            if palindrome(curr):
                path.append(curr)
                dfs(i+1)
                path.pop()
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC93 - 复原 IP 地址&lt;/h3&gt;
&lt;p&gt;跟上一题类似的划分类问题，判断每个划分合不合理，出了额外判断最后是不是被分为了4段之外，还有每段不超过3位、前导0等，总体难在各种情况都考虑到。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def restoreIpAddresses(self, s: str) -&amp;gt; List[str]:
    def isValid(string:str):
        if string[0] == &apos;0&apos; and len(string)&amp;gt;1:
            return False
        num = int(string)
        return 0&amp;lt;=num&amp;lt;=255
    
    path = []
    ans = []
    def dfs(start):
        if len(path)&amp;gt;4:
            return
        if start == len(s):
            if len(path) == 4:
                ans.append(&quot;.&quot;.join(path))
            return
        # 这里要限制最多三位
        for i in range(start,min(start+3,len(s))):
            curr = s[start:i+1]
            if isValid(curr):
                path.append(curr)
                dfs(i+1)
                path.pop()
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;棋盘搜索&lt;/h2&gt;
&lt;h3&gt;LC51 - N 皇后&lt;/h3&gt;
&lt;p&gt;N皇后需要用集合存储一下行、列、主对角、副对角是否被用了。其中主对角和副对角就是用坐标的关系，主对角作差定值，副对角求和定值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def solveNQueens(self, n: int) -&amp;gt; List[List[str]]:
    col, diag1, diag2 = set(), set(), set()
    # 由于每一行最多放一个，用dfs(i)表示放第i行
    ans = []
    grid = [[&apos;.&apos;]*n for _ in range(n)]
    def dfs(i):
        if i == n:
            ans.append([&quot;&quot;.join(row) for row in grid])
            return
        for j in range(n):
            if j not in col and i+j not in diag1 and i-j not in diag2:
                grid[i][j] = &apos;Q&apos;
                col.add(j)
                diag1.add(i+j)
                diag2.add(i-j)
                dfs(i+1)
                grid[i][j] = &apos;.&apos;
                col.remove(j)
                diag1.remove(i+j)
                diag2.remove(i-j)
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC52 - N 皇后 II&lt;/h3&gt;
&lt;p&gt;基本是N皇后的简化版，只要统计数目。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def totalNQueens(self, n: int) -&amp;gt; int:
    col, diag1, diag2 = set(), set(), set()
    # 由于每一行最多放一个，用dfs(i)表示放第i行
    count = 0
    def dfs(i):
        nonlocal count
        if i == n:
            count += 1
            return
        for j in range(n):
            if j not in col and i+j not in diag1 and i-j not in diag2:
                col.add(j)
                diag1.add(i+j)
                diag2.add(i-j)
                dfs(i+1)
                col.remove(j)
                diag1.remove(i+j)
                diag2.remove(i-j)
    dfs(0)
    return count
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC37 - 解数独&lt;/h3&gt;
&lt;p&gt;这里有三个要求，首先是行集合，然后是列结合，最后还有一个3x3box内的集合，这个我们用取余实现找到在第几个小方格内。&lt;/p&gt;
&lt;p&gt;然后，我们是通过找出所有空位，然后用dfs去一个一个填，看看能不能找到解决问题的答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solveSudoku(self, board: list[list[str]]) -&amp;gt; None:
    rows = [set() for _ in range(9)]
    cols = [set() for _ in range(9)]
    boxes = [set() for _ in range(9)]
    # 先找出所有空位，然后dfs去填
    spaces = []

    for i in range(9):
        for j in range(9):
            if board[i][j] == &quot;.&quot;:
                spaces.append((i, j))
            else:
                num = board[i][j]
                rows[i].add(num)
                cols[j].add(num)
                # 还有3x3也要去重合
                boxes[(i // 3) * 3 + j // 3].add(num)

    def dfs(idx: int) -&amp;gt; bool:
        if idx == len(spaces):
            return True

        i, j = spaces[idx]
        box_idx = (i // 3) * 3 + j // 3

        for num in map(str, range(1, 10)):
            if num in rows[i]:
                continue
            if num in cols[j]:
                continue
            if num in boxes[box_idx]:
                continue

            board[i][j] = num
            rows[i].add(num)
            cols[j].add(num)
            boxes[box_idx].add(num)

            if dfs(idx + 1):
                return True

            board[i][j] = &quot;.&quot;
            rows[i].remove(num)
            cols[j].remove(num)
            boxes[box_idx].remove(num)

        return False

    dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;回溯剪枝&lt;/h2&gt;
&lt;p&gt;剪枝的目标是提前停止没有意义的搜索。常见剪枝包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;剩余目标小于 0，直接返回。&lt;/li&gt;
&lt;li&gt;排序后遇到同层重复元素，跳过。&lt;/li&gt;
&lt;li&gt;当前路径长度已经超过限制，返回。&lt;/li&gt;
&lt;li&gt;当前前缀在 Trie 中不存在，返回。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;剪枝一定要保证不漏答案。比如组合去重里 &lt;code&gt;i &amp;gt; start and nums[i] == nums[i - 1]&lt;/code&gt; 是“同层去重”，不能写成所有重复都跳过。&lt;/p&gt;
&lt;h2&gt;回溯常见模板&lt;/h2&gt;
&lt;p&gt;组合/子集模板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(start):
    ans.append(path[:])
    for i in range(start, len(nums)):
        path.append(nums[i])
        dfs(i + 1)
        path.pop()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;排列模板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs():
    if len(path) == len(nums):
        ans.append(path[:])
        return
    for i in range(len(nums)):
        if used[i]:
            continue
        used[i] = True
        path.append(nums[i])
        dfs()
        path.pop()
        used[i] = False
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;图的遍历与染色&lt;/h1&gt;
&lt;h2&gt;图的表示方式&lt;/h2&gt;
&lt;p&gt;常见图表示有两种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;邻接表：&lt;code&gt;graph[u]&lt;/code&gt; 存所有从 &lt;code&gt;u&lt;/code&gt; 能到达的点，适合稀疏图和大多数 LeetCode 图题。&lt;/li&gt;
&lt;li&gt;邻接矩阵：&lt;code&gt;matrix[i][j]&lt;/code&gt; 表示 &lt;code&gt;i&lt;/code&gt; 和 &lt;code&gt;j&lt;/code&gt; 是否相连，适合节点数较少或题目直接给矩阵。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;邻接表和邻接矩阵&lt;/h2&gt;
&lt;p&gt;边列表建邻接表时，无向图要加两条边，有向图只加一条边：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph = [[] for _ in range(n)]
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)  # 无向图才需要这一句
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;有向图和无向图&lt;/h2&gt;
&lt;p&gt;无向图搜索重点是连通性和二分图。有向图除了连通性，还经常涉及环、依赖关系、拓扑排序。&lt;/p&gt;
&lt;h2&gt;图遍历中的 visited&lt;/h2&gt;
&lt;p&gt;无向图一般用 &lt;code&gt;visited&lt;/code&gt; 防止回头。有向图判环常用三色标记：&lt;code&gt;0&lt;/code&gt; 未访问，&lt;code&gt;1&lt;/code&gt; 当前路径中，&lt;code&gt;2&lt;/code&gt; 已完成。&lt;/p&gt;
&lt;h2&gt;二分图判断&lt;/h2&gt;
&lt;h3&gt;LC785 - 判断二分图&lt;/h3&gt;
&lt;p&gt;二分图是图恰好能分为两边的节点，左边全部指向右边。（如果能将一个图的节点集合分割成两个独立的子集 A 和 B ，并使图中的每一条边的两个节点一个来自 A 集合，一个来自 B 集合，就将这个图称为 二分图 。）&lt;/p&gt;
&lt;p&gt;以下情况可考虑有二分图：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;能不能把人/点分成两组，使得有关系的两个点不在同组&lt;/li&gt;
&lt;li&gt;相邻节点不能有相同颜色&lt;/li&gt;
&lt;li&gt;判断图是否可以用两种颜色染色&lt;/li&gt;
&lt;li&gt;图中是否存在奇数环&lt;/li&gt;
&lt;li&gt;敌对关系、互斥关系、分组问题&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;用DFS来判断二分图，可以用color数组给每个节点染色（1/-1），如果一个节点是1，邻居必须是-1，反之亦然。我们用这种方式走一条路径，如果和邻居撞色就失败了。我们从每个还没涂色的点进行dfs。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def isBipartite(self, graph: list[list[int]]) -&amp;gt; bool:
    n = len(graph)
    # 默认都没涂色
    color = [0] * n

    def dfs(node, c):
        color[node] = c

        for nxt in graph[node]:
            if color[nxt] == 0:
                if not dfs(nxt, -c):
                    return False
            elif color[nxt] == c:
                return False

        return True

    for i in range(n):
        if color[i] == 0:
            if not dfs(i, 1):
                return False

    return True
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC886 - 可能的二分法&lt;/h3&gt;
&lt;p&gt;我们用dislikes建图，然后看看能不能二分就可以了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import List

def possibleBipartition(self, n: int, dislikes: List[List[int]]) -&amp;gt; bool:
    graph = [[] for _ in range(n)]
    for d in dislikes:
        graph[d[0]-1].append(d[1]-1)
        graph[d[1]-1].append(d[0]-1)

    # 接下来，判断graph是否是二分图即可
    color = [0] * n 
    def dfs(node,c):
        color[node] = c
        for nxt in graph[node]:
            if color[nxt] == 0:
                if not dfs(nxt,-c):
                    return False
            elif color[nxt] == c:
                return False
        return True

    for i in range(n):
        if color[i] == 0:
            if not dfs(i,1):
                return False
    return True
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;克隆图&lt;/h2&gt;
&lt;h3&gt;LC133 - 克隆图&lt;/h3&gt;
&lt;p&gt;数据结构的克隆，都是先建立旧节点-新节点的映射字典，然后用 新.next  = 新（旧.next） 来解决。也就是传统做法，两轮法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Node:
    def __init__(self,val = 0):
        self.val = val
        self.neighbors = []

def cloneGraph(self, node: Node) -&amp;gt; Node:
    if not node:
        return None

    originToClone = {}
    # DFS 遍历图
    def dfs(node:Node):
        if node in originToClone:
            return
        originToClone[node] = Node(node.val)
        for nxt in node.neighbors:
            dfs(nxt)
    
    dfs(node)

    # 开始克隆关系
    for origin, clone in originToClone.items():
        for nxt in origin.neighbors:
            clone.neighbors.append(originToClone[nxt])
    
    return originToClone[node]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以在一次dfs中一遍创建克隆节点，一遍克隆邻居关系。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Node:
    def __init__(self, val=0, neighbors=None):
        self.val = val
        self.neighbors = neighbors if neighbors is not None else []


def cloneGraph(self, node: Node) -&amp;gt; Node:
    if not node:
        return None

    originToClone = {}

    # 每层dfs负责创建并返回clone节点，然后在dfs中也克隆关系
    def dfs(node):
        if node in originToClone:
            return originToClone[node]

        clone = Node(node.val)
        originToClone[node] = clone

        for nxt in node.neighbors:
            clone.neighbors.append(dfs(nxt))

        return clone

    return dfs(node)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;图遍历常见模板&lt;/h2&gt;
&lt;p&gt;无向图连通性：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if node in visited:
        return
    visited.add(node)
    for nxt in graph[node]:
        dfs(nxt)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有向图判环：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if state[node] == 1:
        return False
    if state[node] == 2:
        return True
    state[node] = 1
    for nxt in graph[node]:
        if not dfs(nxt):
            return False
    state[node] = 2
    return True
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;拓扑排序：有向无环图上的遍历&lt;/h1&gt;
&lt;h2&gt;什么是拓扑排序&lt;/h2&gt;
&lt;p&gt;拓扑排序是把有向图中的节点排成一个线性顺序，使得每条边 &lt;code&gt;u -&amp;gt; v&lt;/code&gt; 都满足 &lt;code&gt;u&lt;/code&gt; 在 &lt;code&gt;v&lt;/code&gt; 前面。它只存在于有向无环图中。&lt;/p&gt;
&lt;h2&gt;入度表和队列&lt;/h2&gt;
&lt;p&gt;入度表示有多少条边指向当前节点。BFS 拓扑排序会先把所有入度为 0 的节点入队，然后每弹出一个节点，就把它指向的节点入度减 1。&lt;/p&gt;
&lt;h2&gt;DFS 拓扑排序&lt;/h2&gt;
&lt;p&gt;DFS 拓扑排序通常用后序收集。一个节点的所有后继都处理完后，再把当前节点加入答案，最后把答案反转。&lt;/p&gt;
&lt;h2&gt;BFS 拓扑排序&lt;/h2&gt;
&lt;p&gt;BFS 拓扑排序就是不断删除入度为 0 的节点。如果最后处理的节点数少于总节点数，说明图中有环。&lt;/p&gt;
&lt;h2&gt;课程表问题&lt;/h2&gt;
&lt;h3&gt;LC207 - 课程表&lt;/h3&gt;
&lt;p&gt;这一题实际上就是判断有向图是否有环，我们先构成邻接表，然后一层一层剥下来。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def canFinish(self, numCourses: int, prerequisites: list[list[int]]) -&amp;gt; bool:
    graph = [[] for _ in range(numCourses)]
    indegree = [0] * numCourses
    for u,v in prerequisites:
        graph[v].append(u)
        indegree[u]+=1

    # 开始拓扑排序
    q = deque()
    for i in range(numCourses):
        if indegree[i] == 0:
            q.append(i)
    
    count = 0
    while q:
        course = q.popleft()
        count += 1
        for nxt in graph[course]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                q.append(nxt)
    
    return count == numCourses
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有向图判环，也可以使用DFS染色。我们可以用三种状态来表示节点状态，0没访问、1当前递归路径、2已经访问完成确认安全。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def canFinish(self, numCourses: int, prerequisites: list[list[int]]) -&amp;gt; bool:
    graph = [[] for _ in range(numCourses)]
    for u,v in prerequisites:
        graph[v].append(u)

    # 开始dfs
    visited = [0] * numCourses

    # 不碰到1，碰到2，就算完成任务。然后再给完成任务的节点标记为2。
    def dfs(course):
        if visited[course] == 1:
            return False
        if visited[course] == 2:
            return True
        
        visited[course] = 1

        for nxt in graph[course]:
            if not dfs(nxt):
                return False
        visited[course] = 2
        return True
    
    for i in range(numCourses):
        if visited[i] == 0:
            if not dfs(i):
                return False
    return True
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC210 - 课程表 II&lt;/h3&gt;
&lt;p&gt;本题需要返回学习完的顺序，其实就是在拓扑排序的时候收集一下答案，把count换成ans即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def findOrder(self, numCourses: int, prerequisites: list[list[int]]) -&amp;gt; list[int]:
    graph = [[] for _ in range(numCourses)]
    indegree = [0] * numCourses
    for u,v in prerequisites:
        graph[v].append(u)
        indegree[u]+=1

    # 开始拓扑排序
    q = deque()
    for i in range(numCourses):
        if indegree[i] == 0:
            q.append(i)
    
    ans = []
    while q:
        course = q.popleft()
        ans.append(course)
        for nxt in graph[course]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                q.append(nxt)
    
    if len(ans) == numCourses:
        return ans
    return []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么，同理，我们在dfs中多维持一个ans，也可以得到答案。不过顺序是反过来的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    graph = [[] for _ in range(numCourses)]
    for u,v in prerequisites:
        graph[v].append(u)

    # 开始dfs
    visited = [0] * numCourses
    ans = []

    # 不碰到1，碰到2，就算完成任务。然后再给完成任务的节点标记为2。
    def dfs(course):
        if visited[course] == 1:
            return False
        if visited[course] == 2:
            return True
        
        visited[course] = 1

        for nxt in graph[course]:
            if not dfs(nxt):
                return False
        visited[course] = 2
        # 后序收集
        ans.append(course)
        return True
    
    for i in range(numCourses):
        if visited[i] == 0:
            if not dfs(i):
                return []
    return ans[::-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC802 - 找到最终的安全状态&lt;/h3&gt;
&lt;p&gt;这一题说白了就是看从一个节点出发，有没有环。我们使用dfs比较方便。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def eventualSafeNodes(self, graph: list[list[int]]) -&amp;gt; list[int]:
    n = len(graph)
    visited = [0] * n
    
    def dfs(node):
        if visited[node] == 1:
            return False
        if visited[node] == 2:
            return True
        
        visited[node] = 1

        for nxt in graph[node]:
            if not dfs(nxt):
                return False
        
        visited[node] = 2
        return True
    
    ans = []
    for i in range(n):
        if dfs(i):
            ans.append(i)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;拓扑排序常见模板&lt;/h2&gt;
&lt;p&gt;BFS 拓扑排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

graph = [[] for _ in range(n)]
indegree = [0] * n

for u, v in edges:
    graph[u].append(v)
    indegree[v] += 1

q = deque(i for i in range(n) if indegree[i] == 0)
order = []

while q:
    cur = q.popleft()
    order.append(cur)
    for nxt in graph[cur]:
        indegree[nxt] -= 1
        if indegree[nxt] == 0:
            q.append(nxt)

has_cycle = len(order) != n
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DFS 判环：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;state = [0] * n

def dfs(node):
    if state[node] == 1:
        return False
    if state[node] == 2:
        return True
    state[node] = 1
    for nxt in graph[node]:
        if not dfs(nxt):
            return False
    state[node] = 2
    return True
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;搜索优化&lt;/h1&gt;
&lt;h2&gt;剪枝&lt;/h2&gt;
&lt;p&gt;剪枝就是减少无意义分支。最常见的剪枝信号是：越界、已经访问、当前和超过目标、剩余数量不够、同层重复、前缀不存在。&lt;/p&gt;
&lt;h2&gt;记忆化搜索&lt;/h2&gt;
&lt;p&gt;当 DFS 中同一个状态会被重复计算时，可以用字典或数组记忆化。典型状态包括 &lt;code&gt;(i, j)&lt;/code&gt;、&lt;code&gt;(idx, remain)&lt;/code&gt;、&lt;code&gt;node&lt;/code&gt; 等。&lt;/p&gt;
&lt;h2&gt;状态压缩&lt;/h2&gt;
&lt;p&gt;当状态里包含一组元素是否被使用，可以用二进制位压缩。比如 &lt;code&gt;mask&lt;/code&gt; 的第 &lt;code&gt;i&lt;/code&gt; 位表示第 &lt;code&gt;i&lt;/code&gt; 个元素是否已经使用。&lt;/p&gt;
&lt;h2&gt;双向搜索&lt;/h2&gt;
&lt;p&gt;双向搜索适合起点和终点都明确、分支很大的最短路问题，比如单词接龙。从两边同时扩展，可以显著减少搜索层数。&lt;/p&gt;
&lt;h2&gt;A* 搜索&lt;/h2&gt;
&lt;p&gt;A* 是带启发式函数的最短路搜索。普通面试里不常考，知道它是在 Dijkstra/BFS 的基础上用估价函数优先探索更可能接近终点的状态即可。&lt;/p&gt;
&lt;h2&gt;搜索中的复杂度估算&lt;/h2&gt;
&lt;p&gt;DFS/BFS 的复杂度通常看状态数和转移数。回溯题可以粗略估算为搜索树节点数；图遍历通常是 &lt;code&gt;O(V + E)&lt;/code&gt;；网格遍历通常是 &lt;code&gt;O(mn)&lt;/code&gt;。&lt;/p&gt;
&lt;h1&gt;搜索与遍历题目的分类判断&lt;/h1&gt;
&lt;h2&gt;看题目是否要求遍历所有节点&lt;/h2&gt;
&lt;p&gt;如果题目问数量、面积、连通性，通常要遍历所有可能节点。&lt;/p&gt;
&lt;h2&gt;看题目是否要求最短步数&lt;/h2&gt;
&lt;p&gt;无权图最短步数优先 BFS。&lt;/p&gt;
&lt;h2&gt;看题目是否需要枚举所有方案&lt;/h2&gt;
&lt;p&gt;所有方案、所有组合、所有路径，一般是 DFS/回溯。&lt;/p&gt;
&lt;h2&gt;看题目是否存在重复状态&lt;/h2&gt;
&lt;p&gt;有重复状态就要考虑 &lt;code&gt;visited&lt;/code&gt;、记忆化或原地标记。&lt;/p&gt;
&lt;h2&gt;看题目是否需要撤销选择&lt;/h2&gt;
&lt;p&gt;如果同一个元素在不同路径里还能被再次使用，需要回溯撤销选择。&lt;/p&gt;
&lt;h2&gt;看题目是否有方向和依赖关系&lt;/h2&gt;
&lt;p&gt;有依赖关系通常建有向图；如果还要判断能不能完成，优先想到拓扑排序或 DFS 判环。&lt;/p&gt;
&lt;h1&gt;搜索与遍历常见模板&lt;/h1&gt;
&lt;h2&gt;二叉树 DFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if not node:
        return
    dfs(node.left)
    dfs(node.right)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;二叉树 BFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;q = deque([root])
while q:
    for _ in range(len(q)):
        node = q.popleft()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;图 DFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def dfs(node):
    if node in visited:
        return
    visited.add(node)
    for nxt in graph[node]:
        dfs(nxt)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;图 BFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;q = deque([start])
visited = {start}
while q:
    cur = q.popleft()
    for nxt in graph[cur]:
        if nxt not in visited:
            visited.add(nxt)
            q.append(nxt)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;网格 DFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def dfs(i, j):
    if not (0 &amp;lt;= i &amp;lt; m and 0 &amp;lt;= j &amp;lt; n):
        return
    if is_blocked(i, j):
        return
    grid[i][j] = VISITED
    for dx, dy in directions:
        dfs(i + dx, j + dy)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;网格 BFS 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;q = deque([(sx, sy)])
visited = {(sx, sy)}
while q:
    x, y = q.popleft()
    for dx, dy in directions:
        nx, ny = x + dx, y + dy
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;回溯模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def dfs(start):
    if 满足条件:
        ans.append(path[:])
    for i in range(start, len(nums)):
        path.append(nums[i])
        dfs(i + 1)
        path.pop()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;拓扑排序模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;q = deque(i for i in range(n) if indegree[i] == 0)
while q:
    cur = q.popleft()
    for nxt in graph[cur]:
        indegree[nxt] -= 1
        if indegree[nxt] == 0:
            q.append(nxt)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;搜索与遍历问题总结&lt;/h1&gt;
&lt;p&gt;搜索与遍历题的本质是：先把题目抽象成节点和边，再决定用 DFS、BFS、回溯还是拓扑排序。DFS 适合深入和枚举，BFS 适合最短路和扩散，回溯适合所有方案，拓扑排序适合有向依赖。写代码时只要抓住状态、选择、出口、去重和剪枝，绝大多数题都能落到固定模板上。&lt;/p&gt;
</content:encoded></item><item><title>算法总结-栈与队列</title><link>https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E6%A0%88%E4%B8%8E%E9%98%9F%E5%88%97/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E6%A0%88%E4%B8%8E%E9%98%9F%E5%88%97/</guid><description>总结汇总一下栈与队列题型。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;栈与队列的核心理解&lt;/h1&gt;
&lt;h2&gt;什么是栈&lt;/h2&gt;
&lt;p&gt;栈是一种 &lt;strong&gt;后进先出&lt;/strong&gt;（LIFO, Last In First Out）的数据结构。最后放进去的元素，最先被拿出来。&lt;/p&gt;
&lt;p&gt;最常见的几个动作是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;push&lt;/code&gt;：压栈&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pop&lt;/code&gt;：弹栈&lt;/li&gt;
&lt;li&gt;&lt;code&gt;top / peek&lt;/code&gt;：查看栈顶&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;栈的直觉非常像一摞盘子。你只能从最上面继续放，也只能从最上面拿。&lt;/p&gt;
&lt;p&gt;刷题里，栈最常用于解决下面几类问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;配对与匹配，比如括号匹配&lt;/li&gt;
&lt;li&gt;撤销、回退、消消乐&lt;/li&gt;
&lt;li&gt;表达式求值&lt;/li&gt;
&lt;li&gt;单调栈维护“下一个更大/更小元素”&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;什么是队列&lt;/h2&gt;
&lt;p&gt;队列是一种 &lt;strong&gt;先进先出&lt;/strong&gt;（FIFO, First In First Out）的数据结构。最先进入队列的元素，会最先离开。&lt;/p&gt;
&lt;p&gt;常见动作是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;push / offer&lt;/code&gt;：入队&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pop / poll&lt;/code&gt;：出队&lt;/li&gt;
&lt;li&gt;&lt;code&gt;front / peek&lt;/code&gt;：查看队头&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;队列非常像排队买饭。先来的人先处理，后来的只能排在后面。&lt;/p&gt;
&lt;p&gt;刷题里，队列最常用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;按顺序处理元素&lt;/li&gt;
&lt;li&gt;层序遍历&lt;/li&gt;
&lt;li&gt;BFS 最短步数问题&lt;/li&gt;
&lt;li&gt;滑动窗口&lt;/li&gt;
&lt;li&gt;单调队列维护窗口最值&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;栈和队列的区别&lt;/h2&gt;
&lt;p&gt;最核心的区别其实只有一句话：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;栈：后进先出&lt;/li&gt;
&lt;li&gt;队列：先进先出&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也正因为这个区别，它们擅长处理的问题也不同：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;栈更擅长“最近的、嵌套的、需要回退的”关系&lt;/li&gt;
&lt;li&gt;队列更擅长“按到达顺序逐个处理”的关系&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以很多题目不是在考你会不会写 &lt;code&gt;append&lt;/code&gt; 和 &lt;code&gt;pop&lt;/code&gt;，而是在考你：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;这个问题需要的是最近进入的元素，还是最早进入的元素？
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;什么情况下想到栈&lt;/h2&gt;
&lt;p&gt;看到下面这些特征，优先想栈：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;括号匹配、标签匹配、路径回退&lt;/li&gt;
&lt;li&gt;撤销、恢复、消消乐、相邻抵消&lt;/li&gt;
&lt;li&gt;表达式求值&lt;/li&gt;
&lt;li&gt;需要维护“最近一个比当前大/小”的元素&lt;/li&gt;
&lt;li&gt;需要在扫描过程中保存“还没处理完的历史信息”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话记忆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;只要题目和“最近的未完成状态”有关，就很容易想到栈。
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;什么情况下想到队列&lt;/h2&gt;
&lt;p&gt;看到下面这些味道，优先想队列：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;层序遍历&lt;/li&gt;
&lt;li&gt;按轮次推进&lt;/li&gt;
&lt;li&gt;最少步数、最短操作次数&lt;/li&gt;
&lt;li&gt;数据流按顺序进入，旧元素会自然淘汰&lt;/li&gt;
&lt;li&gt;滑动窗口&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话记忆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;只要题目强调“顺序推进、一层一层扩散、最先进入最先处理”，就很容易想到队列。
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Python 中的栈、队列、双端队列、优先队列&lt;/h2&gt;
&lt;p&gt;Python 里最常用的几个容器如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普通栈：直接用 &lt;code&gt;list&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;普通队列 / 双端队列：用 &lt;code&gt;collections.deque&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;优先队列：用 &lt;code&gt;heapq&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;list.append()&lt;/code&gt; + &lt;code&gt;list.pop()&lt;/code&gt; 就可以当栈用&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deque.append()&lt;/code&gt; / &lt;code&gt;deque.popleft()&lt;/code&gt; 很适合写队列&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heapq&lt;/code&gt; 默认是小根堆，如果想模拟大根堆，通常存相反数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;刷题时其实不用纠结“我是不是在手写一个正式数据结构”，更重要的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;我现在需要维护哪种顺序？
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;基础栈题：后进先出&lt;/h1&gt;
&lt;h2&gt;括号匹配&lt;/h2&gt;
&lt;h3&gt;LC20 - 有效的括号&lt;/h3&gt;
&lt;p&gt;这是最基础的栈应用题，我们只需要用一个栈来决定就行。首先用字典记录右括号对应的左括号，右括号遇到左括号必须弹出，不然就是无效的，最后栈非空也是无效。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def isValid(self, s: str) -&amp;gt; bool:
    pairs = {&apos;)&apos;: &apos;(&apos;, &apos;]&apos;: &apos;[&apos;, &apos;}&apos;: &apos;{&apos;}
    stack = []

    for ch in s:
        if ch not in pairs:
            stack.append(ch)
            continue
        if not stack or stack.pop() != pairs[ch]:
            return False

    return not stack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果只有一种括号，那么有另外一种思路，用左括号数量去判断有效性。如果要让字符串有效，那么必须每个右括号 ) 的左边必须有一个左括号 ( 和它匹配。所以我们维持一个变量left，遇到左括号+，遇到右括号-，这样中间是否会右括号太多以及最后只要判断是不是正好抵消就行。（其实与栈思路一样，只不过只用了一个变量）。本题不行，因为有三种括号，所以要加大统计量。&lt;/p&gt;
&lt;h3&gt;LC921 - 使括号有效的最少添加&lt;/h3&gt;
&lt;p&gt;只有满足下面几点之一，括号字符串才是有效的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;它是一个空字符串，或者&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;它可以被写成 AB （A 与 B 连接）, 其中 A 和 B 都是有效字符串，或者&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;它可以被写作 (A)，其中 A 是有效字符串。
给定一个括号字符串 s ，在每一次操作中，你都可以在字符串的任何位置插入一个括号&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;例如，如果 s = &quot;()))&quot; ，你可以插入一个开始括号为 &quot;(()))&quot; 或结束括号为 &quot;())))&quot; 。
返回 为使结果字符串 s 有效而必须添加的最少括号数。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一题可以回到需求的解法了，我们维持插入次数res、右括号的需求量need。如果是左括号，need+1即可；如果是右括号，我们不仅要-1，还要判断-1是不是变成刚需左括号了，如果是这样必须立刻补左括号，res+1。这样，我们最终返回res+need就是需要插入的总次数。&lt;/p&gt;
&lt;p&gt;总而言之，我们在插入过程中维持有效性。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minAddToMakeValid(self, s: str) -&amp;gt; int:
    res = 0
    need = 0
    for ch in s:
        if ch == &apos;(&apos;:
            need += 1
        if ch == &apos;)&apos;:
            need -= 1
            if need == -1:
                res += 1
                need += 1
    return res + need
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1541 - 平衡括号字符串的最少插入次数&lt;/h3&gt;
&lt;p&gt;本题和上一题的区别在于，一个left要对应两个右括号了，简单改一下就能出答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minInsertions(self, s: str) -&amp;gt; int:
    res = 0
    need = 0

    for ch in s:
        if ch == &apos;(&apos;:
            # 当右括号需求为奇数，需要插入一个右括号到这个左括号前面，避免前面不匹配了。
            if need % 2 == 1:
                res += 1
                need -= 1
            # 插入一个左括号
            need += 2
        else:
            need -= 1
            if need == -1:
                res += 1
                need = 1

    return res + need
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC1249 - 移除无效的括号&lt;/h3&gt;
&lt;p&gt;这题要求从一个字符串中删除最小数量的括号，让括号有效。字符串中有一些无关小写字母。按照LC20相同的代码，其实我们就可以直接得到需要删除的个数，但是本题要求的是删除后的字符串，需要更多信息。但是其实改动也不多。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minRemoveToMakeValid(self, s: str) -&amp;gt; str:
    ans = []
    balance = 0  # 当前 ans 里还没匹配掉的 &apos;(&apos; 个数

    for ch in s:
        if ch == &apos;(&apos;:
            ans.append(ch)
            balance += 1
        elif ch == &apos;)&apos;:
            if balance == 0:
                continue
            ans.append(ch)
            balance -= 1
        else:
            ans.append(ch)

    res = []
    for ch in reversed(ans):
        if ch == &apos;(&apos; and balance &amp;gt; 0:
            balance -= 1
            continue
        res.append(ch)

    return &apos;&apos;.join(reversed(res))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一题，当然也可以用栈存待会要删除的下标（只存还没匹配的 ( 的下标），来之后构造答案，思路很自然：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(s: str) -&amp;gt; str:
    stack = []
    chars = list(s)

    for i, ch in enumerate(chars):
        if ch == &apos;(&apos;:
            stack.append(i)
        elif ch == &apos;)&apos;:
            if stack:
                stack.pop()
            else:
                chars[i] = &apos;&apos;

    while stack:
        chars[stack.pop()] = &apos;&apos;

    return &apos;&apos;.join(chars)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;综上，结构匹配型我们就用栈来做，数量欠账类的用res/need，构造答案则是两者皆可，用栈来存储更优雅。&lt;/p&gt;
&lt;h2&gt;表达式与字符串处理&lt;/h2&gt;
&lt;h3&gt;LC150 - 逆波兰表达式求值&lt;/h3&gt;
&lt;p&gt;逆波兰式式经典应用栈求数值的题目。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def evalRPN(self, tokens: list[str]) -&amp;gt; int:
    stack = []

    for ch in tokens:
        if ch not in {&apos;+&apos;, &apos;-&apos;, &apos;*&apos;, &apos;/&apos;}:
            stack.append(int(ch))
        else:
            a = stack.pop()
            b = stack.pop()

            if ch == &apos;+&apos;:
                stack.append(b + a)
            elif ch == &apos;-&apos;:
                stack.append(b - a)
            elif ch == &apos;*&apos;:
                stack.append(b * a)
            else:
                stack.append(int(b / a))

    return stack[-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC71 - 简化路径&lt;/h3&gt;
&lt;p&gt;我们通过curr_name来判断目前两个/直接夹的东西，并采用对应的栈操作。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def simplifyPath(self, path: str) -&amp;gt; str:
    stack = []
    # 核心，用来缓冲目前名称
    curr_name=&quot;&quot;
    # 末尾添加 / 保证全部出栈
    for ch in path +&apos;/&apos;:
        if ch == &apos;/&apos;:
            # 如果是..则出栈
            if curr_name==&quot;..&quot;:
                if stack:
                    stack.pop()
            # 如果不是废话（空或者.），压栈
            elif curr_name !=&quot;&quot; and curr_name!=&quot;.&quot;:
                stack.append(curr_name)
            # 缓冲部分清空
            curr_name = &quot;&quot;
        # 接下来是普通字符，简单入栈
        else:
            curr_name+=ch
    return &quot;/&quot;+&quot;/&quot;.join(stack)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC227 - 基本计算器 II&lt;/h3&gt;
&lt;p&gt;这一题，其实是中缀表达式求值，上一题的逆波兰表达式则是后缀表达式。后缀表达式可以轻松用栈解决，中缀则是可以通过栈来转为后缀。思路如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;遇到操作数直接加入后缀表达式&lt;/li&gt;
&lt;li&gt;遇到左括号直接入栈，然后遇到右括号依次弹出栈内运算符并加入后缀表达式，直到弹出 ( 为止。&lt;/li&gt;
&lt;li&gt;遇到运算符，则比较栈顶的运算符的优先级，如果比栈顶优先级高/栈顶为 ( /栈为空，则直接加入表达式；否则将栈顶弹出加入表达式，然后重新判断。&lt;/li&gt;
&lt;li&gt;重复直到遍历完毕，将栈所有元素弹出，加入后缀表达式&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不过对于本题来说，并不存在括号，我们可以这样来处理：遇到加减立刻将数字（取反）压栈，遇到乘除则立刻和栈顶计算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def calculate(self, s: str) -&amp;gt; int:
    stack = []
    num = 0
    sign = &apos;+&apos;

    for i, ch in enumerate(s):
        # 多位数字
        if ch.isdigit():
            num = num * 10 + int(ch)

        if ch in &apos;+-*/&apos; or i == len(s) - 1:
            if sign == &apos;+&apos;:
                stack.append(num)
            elif sign == &apos;-&apos;:
                stack.append(-num)
            elif sign == &apos;*&apos;:
                stack.append(stack.pop() * num)
            elif sign == &apos;/&apos;:
                stack.append(int(stack.pop() / num))

            sign = ch
            num = 0

    return sum(stack)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为什么可以这么做？这是因为，我们用栈调整了运算顺序。如果是优先级较低的+-，我们先记账，留到最后一起加减；如果是乘除，我们就立刻运算。&lt;/p&gt;
&lt;h3&gt;LC224 - 基本计算器&lt;/h3&gt;
&lt;p&gt;这一题属于有括号，但只有+-。因此，也是有简化的方式：我们不用处理优先级，只需要看看这一层算到了多少、这一层整体前面带的符号是什么。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用res记录当前层结果&lt;/li&gt;
&lt;li&gt;sign记录当前数字前的符号&lt;/li&gt;
&lt;li&gt;num组装多位数&lt;/li&gt;
&lt;li&gt;遇到 ( 将外层res和sign压栈，进入新的一层&lt;/li&gt;
&lt;li&gt;遇到 ) 先结算当前层，再和外层合并&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;def calculate(self, s: str) -&amp;gt; int:
    stack = []
    res = 0
    num = 0
    sign = 1

    for ch in s:
        if ch.isdigit():
            num = num * 10 + int(ch)
        elif ch == &apos;+&apos;:
            res += sign * num
            num = 0
            sign = 1
        elif ch == &apos;-&apos;:
            res += sign * num
            num = 0
            sign = -1
        elif ch == &apos;(&apos;:
            stack.append(res)
            stack.append(sign)
            res = 0
            sign = 1
        elif ch == &apos;)&apos;:
            res += sign * num
            num = 0
            res *= stack.pop()
            res += stack.pop()

    return res + sign * num
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC772 - 基本计算器 III&lt;/h3&gt;
&lt;p&gt;这一题，不仅有括号，且有+-*/，没有什么比较好的方法遍历一次解决了。一般而言，我们可以用栈按照前面的技巧转为后缀表达式，然后用栈计算答案。但是，这一题更好的写法是“递归+栈”，也就是每一层递归只处理一层的表达式，遇到 ( 就递归进去，拿子表达式的结果当成一个普通数字 num 来继续算。&lt;/p&gt;
&lt;p&gt;所以其实这一题可以根据 基本计算器 II 加一个递归入口简单改编出来。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def calculate(self, s: str) -&amp;gt; int:
    def dfs(q):
        num, sign, stk = 0, &quot;+&quot;, []
        while q:
            c = q.popleft()
            if c.isdigit():
                num = num * 10 + int(c)
            if c == &quot;(&quot;:
                num = dfs(q)
            if c in &quot;+-*/)&quot; or not q:
                match sign:
                    case &quot;+&quot;:
                        stk.append(num)
                    case &quot;-&quot;:
                        stk.append(-num)
                    case &quot;*&quot;:
                        stk.append(stk.pop() * num)
                    case &quot;/&quot;:
                        stk.append(int(stk.pop() / num))
                num, sign = 0, c
            if c == &quot;)&quot;:
                break
        return sum(stk)

    return dfs(deque(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;辅助栈与模拟栈&lt;/h2&gt;
&lt;h3&gt;LC155 - 最小栈&lt;/h3&gt;
&lt;p&gt;最小栈是一种支持push、pop、top操作，并能在常数时间内检索到最小元素的栈，一般的解决思路是用两个栈来实现，一个栈stack来保存元素，然后用另一个栈min_stack[i] 表示主栈 stack[0..i] 这一层时的最小值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MinStack:

    def __init__(self):
        self.stack = []
        self.min_stack = []

    def push(self, val: int) -&amp;gt; None:
        # 最小栈为空或者新值不大于旧栈顶
        if not self.min_stack or val &amp;lt;= self.min_stack[-1]:
            self.min_stack.append(val)
        # 否则将最小值再次入栈
        else:
            self.min_stack.append(self.min_stack[-1])
        self.stack.append(val)

    def pop(self) -&amp;gt; None:
        if self.stack:
            self.stack.pop()
        if self.min_stack:
            self.min_stack.pop()

    def top(self) -&amp;gt; int:
        return self.stack[-1]
        

    def getMin(self) -&amp;gt; int:
        return self.min_stack[-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC232 - 用栈实现队列&lt;/h3&gt;
&lt;p&gt;又是数据设计题，我们可以用两个栈来模拟队列，其中一个是常规栈，另一个用于出栈倒腾顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyQueue:
    # 两个栈屁股对屁股实现
    def __init__(self):
        self.st1=[]
        self.st2=[]

    def push(self, x: int) -&amp;gt; None:
        # 入栈操作笔记普通，就是进st1
        self.st1.append(x)

    def pop(self) -&amp;gt; int:
        # 出栈先把st1全倒进st2，然后再出
        # 先调用peek作为前置操作，倒栈写在那里
        self.peek()
        return self.st2.pop()

    def peek(self) -&amp;gt; int:
        if not self.st2:
            # 把 st1 元素压入 st2
            while self.st1:
                self.st2.append(self.st1.pop())
        return self.st2[-1]       

    def empty(self) -&amp;gt; bool:
        return not self.st1 and not self.st2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC225 - 用队列实现栈&lt;/h3&gt;
&lt;p&gt;用两个队列来实现栈，思路如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;push(x)：直接进主队列&lt;/li&gt;
&lt;li&gt;pop()：把前 n-1 个元素转移到辅助队列，最后剩下的那个就是栈顶，弹出它&lt;/li&gt;
&lt;li&gt;操作结束后交换 q1 和 q2，让 q1 继续做主队列&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

class MyStack:

    def __init__(self):
        self.q1 = deque()
        self.q2 = deque()

    def push(self, x: int) -&amp;gt; None:
        # q1正常入队
        self.q1.append(x)

    def pop(self) -&amp;gt; int:
        # 出队前倒腾顺序
        while len(self.q1)&amp;gt;1:
            self.q2.append(self.q1.popleft())
        ans = self.q1.popleft()
        self.q1,self.q2 = self.q2, self.q1
        return ans

    def top(self) -&amp;gt; int:
        while len(self.q1) &amp;gt; 1:
            self.q2.append(self.q1.popleft())
        ans = self.q1.popleft()
        # 多了一句元素归还q2，因为我们不需要真弹出
        self.q2.append(ans)
        self.q1, self.q2 = self.q2, self.q1
        return ans

    def empty(self) -&amp;gt; bool:
        return not self.q1
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;基础队列题：先进先出&lt;/h1&gt;
&lt;h2&gt;普通队列&lt;/h2&gt;
&lt;h3&gt;LC933 - 最近的请求次数&lt;/h3&gt;
&lt;p&gt;本题只记录和目前时间差3000以内的数目，所以我们可以直接用一个队列维护，如果队头和入队元素差距超过3000，直接弹出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class RecentCounter:
    def __init__(self):
        self.q=deque()

    def ping(self, t: int) -&amp;gt; int:
        self.q.append(t)
        while t-self.q[0]&amp;gt;3000:
            self.q.popleft()
        return len(self.q)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC649 - Dota2 参议院&lt;/h3&gt;
&lt;p&gt;本题是一个轮流禁言题，也就是循环回合 + 先手淘汰。按照原始顺序流动，每个 &lt;code&gt;R&lt;/code&gt; 可以禁言一个 &lt;code&gt;D&lt;/code&gt;，每个 &lt;code&gt;D&lt;/code&gt; 可以禁言一个 &lt;code&gt;R&lt;/code&gt;，直到最后只剩同一阵容胜利。&lt;/p&gt;
&lt;p&gt;这一题最好的解法是用两个队列存储还活着的议员下标，然后每次队头下标小的先行动，把对方队头ban掉，然后把自己的下标加上n放到队尾（等下一轮），被ban掉的出队就不会再回来了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def predictPartyVictory(self, senate: str) -&amp;gt; str:
    n = len(senate)
    r_queue = deque()
    d_queue = deque()

    for i, ch in enumerate(senate):
        if ch == &apos;R&apos;:
            r_queue.append(i)
        else:
            d_queue.append(i)

    while r_queue and d_queue:
        r = r_queue.popleft()
        d = d_queue.popleft()

        if r &amp;lt; d:
            r_queue.append(r + n)
        else:
            d_queue.append(d + n)

    return &quot;Radiant&quot; if r_queue else &quot;Dire&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;双端队列&lt;/h2&gt;
&lt;h3&gt;LC641 - 设计循环双端队列&lt;/h3&gt;
&lt;p&gt;这一题在 408 里也很常见。我们需要维持一个 &lt;code&gt;front&lt;/code&gt; 和一个 &lt;code&gt;rear&lt;/code&gt;，把数组想象成一个环，然后对这个环做入队、出队和取模移动。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyCircularDeque:

    def __init__(self, k: int):
        self.capacity = k + 1
        self.q = [0] * self.capacity
        self.front = 0
        self.rear = 0

    def insertFront(self, value: int) -&amp;gt; bool:
        if self.isFull():
            return False
        self.front = (self.front - 1 + self.capacity) % self.capacity
        self.q[self.front] = value
        return True

    def insertLast(self, value: int) -&amp;gt; bool:
        if self.isFull():
            return False
        self.q[self.rear] = value
        self.rear = (self.rear + 1) % self.capacity
        return True

    def deleteFront(self) -&amp;gt; bool:
        if self.isEmpty():
            return False
        self.front = (self.front + 1) % self.capacity
        return True

    def deleteLast(self) -&amp;gt; bool:
        if self.isEmpty():
            return False
        self.rear = (self.rear - 1 + self.capacity) % self.capacity
        return True

    def getFront(self) -&amp;gt; int:
        if self.isEmpty():
            return -1
        return self.q[self.front]

    def getRear(self) -&amp;gt; int:
        if self.isEmpty():
            return -1
        return self.q[(self.rear - 1 + self.capacity) % self.capacity]

    def isEmpty(self) -&amp;gt; bool:
        return self.front == self.rear

    def isFull(self) -&amp;gt; bool:
        return (self.rear + 1) % self.capacity == self.front
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;层序遍历与 BFS&lt;/h2&gt;
&lt;p&gt;队列常被用来实现层序遍历，所以这一组题也很适合放在这里一起理解。&lt;/p&gt;
&lt;h3&gt;LC102 - 二叉树的层序遍历&lt;/h3&gt;
&lt;p&gt;经典层序遍历，需要达到肌肉级别记忆。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

class TreeNode:
    def __init__(self,val =0 ,left = None, right = None):
        self.val = val
        self.left = left
        self.right = right

def levelOrder(self, root: TreeNode) -&amp;gt; list[list[int]]:
    if not root:
        return []
    q = deque([root])
    ans = []
    while q:
        sz = len(q)
        level = []
        for _ in range(sz):
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        ans.append(level)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC107 - 二叉树的层序遍历 II&lt;/h3&gt;
&lt;p&gt;自底向上的层序遍历，和上一题相比只是 &lt;code&gt;ans&lt;/code&gt; 改成从左边添加而已。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def levelOrderBottom(self, root: TreeNode) -&amp;gt; list[list[int]]:
    if not root:
        return []

    q = deque([root])
    ans = deque()

    while q:
        level = []
        for _ in range(len(q)):
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        ans.appendleft(level)

    return list(ans)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC103 - 二叉树的锯齿形层序遍历&lt;/h3&gt;
&lt;p&gt;我们可以根据ans的长度得到奇数层偶数层，从而决定这个level是append还是appendleft。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

class TreeNode:
    def __init__(self,val =0 ,left = None, right = None):
        self.val = val
        self.left = left
        self.right = right

def zigzagLevelOrder(self, root: TreeNode) -&amp;gt; list[list[int]]:
    if not root:
        return []
    q = deque([root])
    ans = []
    while q:
        sz = len(q)
        level = deque()
        if len(ans)%2 == 0:
            for _ in range(sz):
                node = q.popleft()
                level.append(node.val)
                if node.left:
                    q.append(node.left)
                if node.right:
                    q.append(node.right)
        else:
            for _ in range(sz):
                node = q.popleft()
                level.appendleft(node.val)
                if node.left:
                    q.append(node.left)
                if node.right:
                    q.append(node.right)            
        ans.append(list(level))
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC199 - 二叉树的右视图&lt;/h3&gt;
&lt;p&gt;右视图其实就是每层的最后一个节点，所以在普通层序遍历的基础上，取每个 &lt;code&gt;level[-1]&lt;/code&gt; 即可。这题和前面几题的差别不在数据结构，而在“每层要收集哪一个元素”。&lt;/p&gt;
&lt;h2&gt;图论中的队列&lt;/h2&gt;
&lt;h3&gt;LC994 - 腐烂的橘子&lt;/h3&gt;
&lt;p&gt;本题用队列来存储腐烂橘子的坐标，弹出的时候让周围的新鲜橘子入队，每层扩散一次，时间 +1。当队列为空时遍历结束，这时候如果还有新鲜橘子就不可能全部腐烂，返回 &lt;code&gt;-1&lt;/code&gt;，否则返回分钟数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def orangesRotting(self, grid: list[list[int]]) -&amp;gt; int:
    m, n = len(grid), len(grid[0])
    q = deque()
    fresh = 0

    for i in range(m):
        for j in range(n):
            if grid[i][j] == 2:
                q.append((i, j))
            elif grid[i][j] == 1:
                fresh += 1

    if fresh == 0:
        return 0

    minutes = 0
    directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]

    while q and fresh &amp;gt; 0:
        for _ in range(len(q)):
            x, y = q.popleft()
            for dx, dy in directions:
                nx, ny = x + dx, y + dy
                if 0 &amp;lt;= nx &amp;lt; m and 0 &amp;lt;= ny &amp;lt; n and grid[nx][ny] == 1:
                    grid[nx][ny] = 2
                    fresh -= 1
                    q.append((nx, ny))
        minutes += 1

    return minutes if fresh == 0 else -1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC752 - 打开转盘锁&lt;/h3&gt;
&lt;p&gt;这一题的目标是最少步数，所以应该用BFS来做，因为BFS是天然的求“无权路最短图”的方法。如果用DFS，会一路走到底，然后要保证路径最短，还要将所有可能路径搜完，再取最小值，复杂度很差，且存在大量重复状态。&lt;/p&gt;
&lt;p&gt;我们用bfs的次数就可以统计进行的步数，所以这一题只要把握好状态更新入队即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

class Solution:
    def update(self, old: str, i: int) -&amp;gt; tuple[str, str]:
        s1 = list(old)
        s2 = list(old)
        num = int(old[i])
        s1[i] = str((num + 9) % 10)
        s2[i] = str((num + 1) % 10)
        return &apos;&apos;.join(s1), &apos;&apos;.join(s2)

    def openLock(self, deadends: list[str], target: str) -&amp;gt; int:
        visited = set(deadends)
        if &apos;0000&apos; in visited:
            return -1

        q = deque([&apos;0000&apos;])
        visited.add(&apos;0000&apos;)
        depth = 0

        while q:
            for _ in range(len(q)):
                cur = q.popleft()
                if cur == target:
                    return depth

                for i in range(4):
                    s1, s2 = self.update(cur, i)
                    for state in (s1, s2):
                        if state not in visited:
                            visited.add(state)
                            q.append(state)

            depth += 1

        return -1

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC773 - 滑动谜题&lt;/h3&gt;
&lt;p&gt;依旧状态更新，依旧最短次数。我们直接一个广搜，用队列存储更新状态。可是，如果每次队列都存储一个二维数组，负担太大了，而且由于我们已经知道是2x3的板子了，可以先预处理好可以相邻下标。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque
class Solution:
    # 我们需要先预处理一个邻居表，用于状态转移
    # 一下分别是2x3板子中，0-5对应的相邻索引
    mapping = [
        [1,3],
        [0,4,2],
        [1,5],
        [0,4],
        [3,1,5],
        [4,2]
    ]

    # 辅助函数，交换元素
    def update_status(self,old,i,j):
        new = old[:]
        new[i], new[j] = new[j], new[i]
        return new

    def slidingPuzzle(self, board: List[List[int]]) -&amp;gt; int:
        # BFS不仅是寻路算法，也是一种暴力搜索算法，暴力穷举的问题BFS都可以用
        target = [1,2,3,4,5,0]
        start = []
        for i in range(len(board)):
            for j in range(len(board[0])):
                start.append(board[i][j])

        q = deque()
        visited = set()
        # 从起点BFS开始搜索
        q.append(start)
        visited.add(tuple(start))

        step = 0
        while q:
            sz = len(q)
            for _ in range(sz):
                cur = q.popleft()
                if cur == target:
                    return step
                # 如果不是目标，将所以可能的转移状态写入
                zero_idx = cur.index(0)
                for to_swap in self.mapping[zero_idx]:
                    new = self.update_status(cur,zero_idx,to_swap)
                    if tuple(new) not in visited:
                        q.append(new)
                        visited.add(tuple(new))
            # 一个更新结束（bfs层）
            step += 1
        return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;单调栈：维护下一个更大或更小&lt;/h1&gt;
&lt;h2&gt;单调栈的核心思想&lt;/h2&gt;
&lt;p&gt;单调栈本质上还是栈，只不过我们在压栈时额外维护了一个“单调性”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要么从栈底到栈顶单调递增&lt;/li&gt;
&lt;li&gt;要么从栈底到栈顶单调递减&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它最擅长解决的问题是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;下一个更大元素&lt;/li&gt;
&lt;li&gt;下一个更小元素&lt;/li&gt;
&lt;li&gt;上一个更大 / 更小元素&lt;/li&gt;
&lt;li&gt;左右第一个打破单调性的位置&lt;/li&gt;
&lt;li&gt;区间贡献问题&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;很多人第一次学单调栈，会觉得它像魔法。其实它做的事情非常朴素：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;新元素一来，就把那些已经不可能成为答案的旧元素弹掉。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以单调栈的关键不在“背模板”，而在想清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;栈里到底存的是值还是下标&lt;/li&gt;
&lt;li&gt;当前元素来了之后，谁已经失去资格了&lt;/li&gt;
&lt;li&gt;弹栈的那一刻，我能不能顺便结算答案&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;下一个更大元素&lt;/h2&gt;
&lt;h3&gt;LC496 - 下一个更大元素 I&lt;/h3&gt;
&lt;p&gt;要找下一个更大元素，单调栈就是非常自然的解法。我们可以用哈希表记录每个元素下一个更大元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def nextGreaterElement(self, nums1: list[int], nums2: list[int]) -&amp;gt; list[int]:
    stack = []
    next_map = {}

    for num in nums2:
        while stack and stack[-1] &amp;lt; num:
            next_map[stack.pop()] = num
        stack.append(num)

    return [next_map.get(num, -1) for num in nums1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC503 - 下一个更大元素 II&lt;/h3&gt;
&lt;p&gt;这一题要求 &lt;code&gt;nums&lt;/code&gt; 中每个元素的下一个更大元素，但是本题数组是循环数组。这一题的解法实际上只要做两个处理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;让循环范围扩大确保每个元素都判断一次是否是下一个元素&lt;/li&gt;
&lt;li&gt;然后让下标定位时取余即可&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;def nextGreaterElements(self, nums: list[int]) -&amp;gt; list[int]:
    n = len(nums)
    ans = [-1] * n
    stack = []  # 存下标
    for i in range(2 * n):
        x = nums[i % n]
        while stack and nums[stack[-1]] &amp;lt; x:
            ans[stack.pop()] = x
        # 额外判断是不是第一次遍历，要不要加下标了
        if i &amp;lt; n:
            stack.append(i)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC739 - 每日温度&lt;/h3&gt;
&lt;p&gt;一道很规整的单调栈题目，存下标，算距离存入。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dailyTemperatures(self, temperatures: list[int]) -&amp;gt; list[int]:
    n = len(temperatures)
    ans = [0]*n
    st = []
    for i in range(n):
        while st and temperatures[st[-1]]&amp;lt;temperatures[i]:
            idx = st.pop()
            ans[idx] = i - idx
        st.append(i)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;找左右边界&lt;/h2&gt;
&lt;h3&gt;LC84 - 柱状图中最大的矩形&lt;/h3&gt;
&lt;p&gt;本质上，也是在寻找左右两侧第一个比当前矩形矮的，然后这时候就可以得出当前矩形能贡献出来的最大矩形。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def largestRectangleArea(self, heights: list[int]) -&amp;gt; int:
    heights = [0] + heights + [0]
    st = []
    max_S = 0
    for i,val in enumerate(heights):
        while st and st[-1][1]&amp;gt;val:
            _,curr_val = st.pop()
            left_idx = st[-1][0]
            right_idx = i
            max_S = max(max_S,(right_idx-left_idx-1)*curr_val)
        st.append((i,val))
    return max_S
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC85 - 最大矩形&lt;/h3&gt;
&lt;p&gt;这一题，实际上是枚举每一行作为底边，来做多个一维柱状图最大矩形问题。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def largestRectangleArea(self, heights: list[int]) -&amp;gt; int:
        heights = [0] + heights + [0]
        stack = []
        ans = 0

        for i, h in enumerate(heights):
            while stack and heights[stack[-1]] &amp;gt; h:
                cur_h = heights[stack.pop()]
                left = stack[-1]
                width = i - left - 1
                ans = max(ans, cur_h * width)
            stack.append(i)

        return ans

    def maximalRectangle(self, matrix: list[list[str]]) -&amp;gt; int:
        if not matrix or not matrix[0]:
            return 0

        rows, cols = len(matrix), len(matrix[0])
        heights = [0] * cols
        ans = 0

        for i in range(rows):
            for j in range(cols):
                if matrix[i][j] == &apos;1&apos;:
                    heights[j] += 1
                else:
                    # 如果有中断，就不能再计算
                    heights[j] = 0

            ans = max(ans, self.largestRectangleArea(heights))

        return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC901 - 股票价格跨度&lt;/h3&gt;
&lt;p&gt;设计一个算法收集某些股票的每日报价，并返回该股票当日价格的 跨度 。当日股票价格的 跨度 被定义为股票价格小于或等于今天价格的最大连续日数（从今天开始往回数，包括今天）。&lt;/p&gt;
&lt;p&gt;这一题和普通“找下一个更大元素”的题有一点不一样。它不是一次性给完整数组，而是数据流在线输入，所以不能等全部输入完再统一求。&lt;/p&gt;
&lt;p&gt;思路是维护一个单调递减栈，栈里存 &lt;code&gt;(price, span)&lt;/code&gt;。如果当前价格比栈顶大，那么栈顶那一整段跨度都能被当前价格吞掉，于是我们把它们的跨度累加起来。这就是这一题看起来“多了一个累加量”的原因。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class StockSpanner:

    def __init__(self):
        self.stack = []  # (price, span)

    def next(self, price: int) -&amp;gt; int:
        span = 1

        while self.stack and self.stack[-1][0] &amp;lt;= price:
            span += self.stack.pop()[1]

        self.stack.append((price, span))
        return span
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;单调栈变形&lt;/h2&gt;
&lt;h3&gt;LC853 - 车队&lt;/h3&gt;
&lt;p&gt;这一题属于“排序 + 单调栈”的变形。把车按位置从左到右排序后，每辆车都有一个到终点的时间 &lt;code&gt;time = (target - pos) / speed&lt;/code&gt;。如果后车到终点所需时间小于等于前车，那么它最终一定会追上前车，合并成同一个车队。&lt;/p&gt;
&lt;p&gt;所以我们只需要维护一个单调的到达时间栈，能合并的就弹掉，最后栈里剩下的就是车队数量。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def carFleet(self, target: int, position: list[int], speed: list[int]) -&amp;gt; int:
    cars=sorted(zip(position,speed))
    stack=[]
    for pos,spd in cars:
        time=(target-pos)/spd
        while stack and stack[-1]&amp;lt;=time:
            stack.pop()
        stack.append(time)
    return len(stack)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;区间贡献问题&lt;/h2&gt;
&lt;h3&gt;LC907 - 子数组的最小值之和&lt;/h3&gt;
&lt;p&gt;这一题如果直接暴力二重循环弄出所有子数组，会超时。所以必须要转化思路，核心思想是算每个 arr[i] 作为最小值时，能贡献多少个子数组。&lt;/p&gt;
&lt;p&gt;实际上，我们依旧要回到单调栈的原本思想，我们要看 arr[i] 左边第一个比它小的位置、右边第一个小于等于它的位置在哪。left[i] = 从 i 往左，最多能选多少个起点；right[i] = 从 i 往右，最多能选多少个终点；那么，arr[i] 的贡献 = arr[i] * left[i] * right[i]。&lt;/p&gt;
&lt;p&gt;比如，[3,1,2,4]，3左边没有更小的，所以左侧可选一个起点；右侧第一个小于等于它的是1，所以只能是[3]。而1左侧没有比它更小的，起点可以选3或者1，右侧是没有小于等于它的，可以选1、2、4，一共贡献 1&lt;em&gt;2&lt;/em&gt;3 = 6。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def sumSubarrayMins(self, arr: list[int]) -&amp;gt; int:
    # 思路是对每个数找左、右第一个比它小的。
    # left[i] = 从 i 往左，最多能选多少个起点。换言之，就是找到第一个比它小的，之间的都可以当做起点。
    n = len(arr)
    left = [0] * n 
    st = []
    for i in range(n):
        while st and arr[st[-1]] &amp;gt; arr[i]:
            st.pop()
        # 空栈说明arr[i]已经是最小的
        left[i] = i - st[-1] if st else i + 1
        st.append(i)

    # right[i] = 从 i 往右，最多能选多少个终点。由于左边没有等于，我们知道等于也是可以的，选一侧用等于。
    # 操作几乎和左边对称
    right = [0] * n
    st = []
    for i in range(n-1,-1,-1):
        while st and arr[st[-1]] &amp;gt;= arr[i]:
            st.pop()
        right[i] = st[-1] - i if st else n-i
        st.append(i)

    MOD = 10**9 + 7
    ans = 0
    for i in range(n):
        ans = (ans + arr[i]*left[i]*right[i])%MOD
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC2104 - 子数组范围和&lt;/h3&gt;
&lt;p&gt;返回所有连续子数组最小元素和最大元素差值，在上一题中，我们已经求过了最小值贡献，改一下不等于号方向就可以求最大值贡献了。然后，用最大值贡献-最小值贡献，就是本题的答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def subArrayRanges(self, nums: list[int]) -&amp;gt; int:
    n = len(nums)
    left_min = [0] * n
    right_min = [0] * n
    left_max = [0] * n
    right_max = [0] * n
    
    # 开始四遍单调栈求解
    st = []
    for i in range(n):
        while st and nums[st[-1]]&amp;gt;nums[i]:
            st.pop()
        left_min[i] = i - st[-1] if st else i+1
        st.append(i)

    st = []
    for i in range(n):
        while st and nums[st[-1]]&amp;lt;nums[i]:
            st.pop()
        left_max[i] = i - st[-1] if st else i+1
        st.append(i)

    st = []
    for i in range(n-1,-1,-1):
        while st and nums[st[-1]]&amp;gt;=nums[i]:
            st.pop()
        right_min[i] = st[-1] - i if st else n-i
        st.append(i)

    st = []
    for i in range(n-1,-1,-1):
        while st and nums[st[-1]]&amp;lt;=nums[i]:
            st.pop()
        right_max[i] = st[-1] - i if st else n-i
        st.append(i)
    
    # 现在，用最大值贡献-最小值贡献
    ans = [0] * n 
    for i in range(n):
        ans[i] = nums[i]*left_max[i]*right_max[i] - nums[i]*left_min[i]*right_min[i]

    return sum(ans)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本题其实可以细细品味，包含着单调栈的核心思想。&lt;/p&gt;
&lt;h2&gt;单调栈模板&lt;/h2&gt;
&lt;p&gt;最常见的三个模板其实可以这样记：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. 正向扫描，当前元素去结算前面的人
stack = []
for i, x in enumerate(nums):
    while stack and nums[stack[-1]] &amp;lt; x:
        idx = stack.pop()
        # 在这里结算 idx 的答案
    stack.append(i)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 2. 反向扫描，当前元素直接找自己的答案
stack = []
for i in range(len(nums) - 1, -1, -1):
    while stack and stack[-1] &amp;lt;= nums[i]:
        stack.pop()
    ans[i] = stack[-1] if stack else -1
    stack.append(nums[i])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 3. 左右边界模板
heights = [0] + heights + [0]
stack = []
for i, h in enumerate(heights):
    while stack and heights[stack[-1]] &amp;gt; h:
        cur = stack.pop()
        left = stack[-1]
        width = i - left - 1
        # 在这里结算 cur 的贡献
    stack.append(i)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;单调队列：维护滑动窗口最值&lt;/h1&gt;
&lt;h2&gt;单调队列的核心思想&lt;/h2&gt;
&lt;p&gt;单调队列本质上是 &lt;code&gt;deque + 维护单调性&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;和单调栈相比，它最大的不同在于：单调队列往往服务于&lt;strong&gt;滑动窗口&lt;/strong&gt;。所以除了维护单调性，还要解决一个额外问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;队头这个元素过期了吗？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是单调队列的经典操作一般有两步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;新元素进来时，从队尾弹出所有“不如它”的元素&lt;/li&gt;
&lt;li&gt;窗口左端移动时，判断队头是否已经滑出窗口&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以单调队列最擅长的就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;滑动窗口最大值 / 最小值&lt;/li&gt;
&lt;li&gt;窗口合法性判定&lt;/li&gt;
&lt;li&gt;前缀和 + 最优左端点筛选&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;滑动窗口最值&lt;/h2&gt;
&lt;h3&gt;LC239 - 滑动窗口最大值&lt;/h3&gt;
&lt;p&gt;这是单调队列最经典的使用题。我们用deque，保持队头始终是当前窗口的最大值，然后每次验证队头是否过期即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

def maxSlidingWindow(self, nums: list[int], k: int) -&amp;gt; list[int]:
    q = deque()
    ans = []
    for i,val in enumerate(nums):
        while q and nums[q[-1]] &amp;lt;= val:
            q.pop()
        q.append(i)

        # 判断过期
        if q[0] &amp;lt;= i - k:
            q.popleft()
        
        # 判断是否可以录用
        if i &amp;gt;= k - 1:
            ans.append(nums[q[0]])
        
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;前缀和 + 单调队列&lt;/h2&gt;
&lt;h3&gt;LC862 - 和至少为 K 的最短子数组&lt;/h3&gt;
&lt;p&gt;给你一个整数数组 nums 和一个整数 k ，找出 nums 中和至少为 k 的 最短非空子数组 ，并返回该子数组的长度。如果不存在这样的 子数组 ，返回 -1 。(子数组连续)&lt;/p&gt;
&lt;p&gt;我们知道，要想求任意子数组的和，用前缀和就可以解决。这题不能使用滑动窗口，因为 LC862 里可能有负数。有负数时，窗口右扩后，和不一定变大；左缩后，和也不一定变小，所以普通双指针失效。&lt;/p&gt;
&lt;p&gt;构造好前缀和之后，用单调队列维护一批最有希望成为左端点的前缀和下标。为了让和尽量大，我们让最小的前缀和始终保持在队顶，然后遍历的时候每次和当前 pre[i] 做差得到和，大于k之后，将ans更新为较小的坐标长度。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque
from itertools import accumulate

def shortestSubarray(self, nums: list[int], k: int) -&amp;gt; int:
    prefix = [0] + list(accumulate(nums))
    q = deque()
    n = len(nums)
    ans = n+1
    for i in range(n+1):
        while q and prefix[i] - prefix[q[0]] &amp;gt;= k:
            ans = min(ans,i-q.popleft())

        while q and prefix[q[-1]]&amp;gt;=prefix[i]:
            q.pop()
        q.append(i)
    return ans if ans&amp;lt;=n else -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一题代码虽然很短，但思路转化并不直观，所以一定要把“前缀和 + 单调队列”的套路记住。&lt;/p&gt;
&lt;h3&gt;LC1438 - 绝对差不超过限制的最长连续子数组&lt;/h3&gt;
&lt;p&gt;依旧是滑动窗口最值，这次既要最大又要最小，那么好，用两个队列就可以满足。&lt;/p&gt;
&lt;p&gt;这一题包含比较通用的滑动窗口最值求解，请仔细阅读：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestSubarray(self, nums: list[int], limit: int) -&amp;gt; int:
    max_q = deque()
    min_q = deque()
    left = 0
    ans = 0

    for right, x in enumerate(nums):
        while max_q and nums[max_q[-1]] &amp;lt;= x:
            max_q.pop()
        max_q.append(right)

        while min_q and nums[min_q[-1]] &amp;gt;= x:
            min_q.pop()
        min_q.append(right)

        # 过期机制
        while nums[max_q[0]] - nums[min_q[0]] &amp;gt; limit:
            if max_q[0] == left:
                max_q.popleft()
            if min_q[0] == left:
                min_q.popleft()
            left += 1

        ans = max(ans, right - left + 1)

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;单调队列模板&lt;/h2&gt;
&lt;p&gt;单调队列最常见的模板其实就是下面这版：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

q = deque()  # 存下标

for i, x in enumerate(nums):
    while q and nums[q[-1]] &amp;lt;= x:
        q.pop()
    q.append(i)

    if q[0] &amp;lt;= i - k:
        q.popleft()

    if i &amp;gt;= k - 1:
        ans.append(nums[q[0]])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果同时需要维护最大值和最小值，就开两个队列：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个单调递减，维护最大值&lt;/li&gt;
&lt;li&gt;一个单调递增，维护最小值&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而如果题目是 &lt;code&gt;LC862&lt;/code&gt; 这种“前缀和 + 最短子数组”，那么队列里存的就不是原数组元素，而是&lt;strong&gt;前缀和下标&lt;/strong&gt;。这一点要单独记。&lt;/p&gt;
&lt;h1&gt;优先队列：动态维护最值&lt;/h1&gt;
&lt;h2&gt;优先队列的核心思想&lt;/h2&gt;
&lt;p&gt;优先队列的题，核心味道其实非常明显：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;候选集会动态变化，但每一步只关心其中的一个最值。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时就很容易想到堆。&lt;/p&gt;
&lt;p&gt;常见信号包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一边扫描，一边动态加入候选&lt;/li&gt;
&lt;li&gt;每次只需要当前最大 / 当前最小&lt;/li&gt;
&lt;li&gt;Top K&lt;/li&gt;
&lt;li&gt;多路归并&lt;/li&gt;
&lt;li&gt;数据流维护第 k 大 / 中位数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 Python 里，刷题时通常直接用 &lt;code&gt;heapq&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;默认是小根堆&lt;/li&gt;
&lt;li&gt;想要大根堆，就存相反数&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Top K 问题&lt;/h2&gt;
&lt;h3&gt;LC215 - 数组中的第 K 个最大元素&lt;/h3&gt;
&lt;p&gt;Top K问题，可以通过快速选择算法来解决。我们这里先复习一下优化重复元素性能的三路快选。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import random

def findKthLargest(self, nums: list[int], k: int) -&amp;gt; int:
    target = len(nums) - k

    def partition(left: int, right: int) -&amp;gt; tuple[int, int]:
        pivot_idx = random.randint(left, right)
        pivot = nums[pivot_idx]

        nums[pivot_idx], nums[right] = nums[right], nums[pivot_idx]

        lt = left
        i = left
        gt = right

        while i &amp;lt;= gt:
            if nums[i] &amp;lt; pivot:
                nums[lt], nums[i] = nums[i], nums[lt]
                lt += 1
                i += 1
            elif nums[i] &amp;gt; pivot:
                nums[i], nums[gt] = nums[gt], nums[i]
                gt -= 1
            else:
                i += 1

        return lt, gt

    left, right = 0, len(nums) - 1

    while left &amp;lt;= right:
        lt, gt = partition(left, right)

        if lt &amp;lt;= target &amp;lt;= gt:
            return nums[target]
        elif target &amp;lt; lt:
            right = lt - 1
        else:
            left = gt + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后，我们就进入这一题的优先队列写法。一般优先队列题优先用heapq即可，堆就是优先队列的常见实现方式。我们通过取反模拟大根堆，然后弹出k-1个之后得到第k个最大元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq

def findKthLargest(self, nums: list[int], k: int) -&amp;gt; int:
    pq = [-x for x in nums]
    heapq.heapify(pq)

    for _ in range(k - 1):
        heapq.heappop(pq)

    return -heapq.heappop(pq)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另一种做法是固定大小的小根堆，这样会一直保留最小的k个，如果新元素比堆顶大，就换堆顶，到最后堆顶就会变成最大的k个中的最小的，也就是第k个最大元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq

def findKthLargest(self, nums: list[int], k: int) -&amp;gt; int:
    pq = []
    for num in nums:
        heapq.heappush(pq,num)
        if len(pq)&amp;gt;k:
            heapq.heappop(pq)
    return pq[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC347 - 前 K 个高频元素&lt;/h3&gt;
&lt;p&gt;这一题最符合直觉的做法是哈希表+小根堆，我们维持大小为k的小根堆，遇到大的就替换掉堆顶，最终就会剩余前k个高频元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq
from collections import Counter

def topKFrequent(self, nums: list[int], k: int) -&amp;gt; list[int]:
    freq_map = Counter(nums)
    pq = []
    for num, freq in freq_map.items():
        heapq.heappush(pq, (freq, num))
        if len(pq) &amp;gt; k:
            heapq.heappop(pq)
    
    # 此时只剩下了频率最高的K个元组
    ans = [x[1] for x in pq]
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC703 - 数据流中的第 K 大元素&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import heapq

class KthLargest:

    def __init__(self, k: int, nums: list[int]):
        self.pq = []
        self.k = k
        for num in nums:
            self.add(num)

    def add(self, val: int) -&amp;gt; int:
        heapq.heappush(self.pq,val)
        if len(self.pq)&amp;gt;self.k:
            heapq.heappop(self.pq)
        return self.pq[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;合并多个有序结构&lt;/h2&gt;
&lt;h3&gt;LC23 - 合并 K 个升序链表&lt;/h3&gt;
&lt;p&gt;之前合并k个升序链表是通过分治归并法解决，这里可以使用堆来解决这个问题。我们将所有链表当前节点放在最小堆里，每次取出堆顶构造。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def mergeKLists(self, lists: list[ListNode]) -&amp;gt; ListNode:
    pq = []

    for i, head in enumerate(lists):
        if head:
            heapq.heappush(pq, (head.val, i, head))

    dummy = ListNode()
    p = dummy

    while pq:
        _, i, node = heapq.heappop(pq)
        p.next = node
        p = p.next

        if node.next:
            heapq.heappush(pq, (node.next.val, i, node.next))

    return dummy.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意的是，我们必须要防止 Python 直接比较 &lt;code&gt;node&lt;/code&gt;，因为链表节点本身不能比较大小。所以我们要存 &lt;code&gt;node.val&lt;/code&gt; 和 &lt;code&gt;node&lt;/code&gt; 的元组。但是题目中 &lt;code&gt;node.val&lt;/code&gt; 还是可能会相等，然后顺位又去比较 &lt;code&gt;node&lt;/code&gt;，所以我们还要在中间塞一个 &lt;code&gt;idx&lt;/code&gt;，来保证永远比不到 &lt;code&gt;node&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;LC373 - 查找和最小的 K 对数字&lt;/h3&gt;
&lt;p&gt;最简单的写法就是二重循环全部入大小k的大根堆，每次超限踢出最大的，剩下的就是最小的K个。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq

def kSmallestPairs(self, nums1: list[int], nums2: list[int], k: int) -&amp;gt; list[list[int]]:
    pq = []

    for num1 in nums1:
        for num2 in nums2:
            total = num1 + num2
            heapq.heappush(pq, (-total, num1, num2))
            if len(pq) &amp;gt; k:
                heapq.heappop(pq)

    return [[a, b] for _, a, b in pq]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是，这么写显然丢失了题目中的重要信息，即有序。假设nums1和nums2都有三个，我们可以写出3x3的矩阵，每一行都可以看成一个有序链表，比如1、7、11；2、4、6：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        2   4   6
1   -&amp;gt;  3   5   7
7   -&amp;gt;  9  11  13
11  -&amp;gt; 13  15  17
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;显然，我们就可以套用 LC23 的思路，先把每一行第一个数字入堆，然后弹出当前最小对，再继续看这一行的下一个，直到取满 &lt;code&gt;k&lt;/code&gt; 个。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq

def kSmallestPairs(self, nums1: list[int], nums2: list[int], k: int) -&amp;gt; list[list[int]]:
    if not nums1 or not nums2 or k == 0:
        return []

    pq = []
    ans = []
    # 如果len(nums1)甚至不如k，那么后面的不用看了
    for i in range(min(k, len(nums1))):
        heapq.heappush(pq,(nums1[i]+nums2[0],i,0))

    while pq and len(ans)&amp;lt;k:
        _,i,j = heapq.heappop(pq)
        ans.append([nums1[i],nums2[j]])

        if j+1&amp;lt;len(nums2):
            heapq.heappush(pq,(nums1[i]+nums2[j+1],i,j+1))

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;数据流与对顶堆&lt;/h2&gt;
&lt;h3&gt;LC295 - 数据流的中位数&lt;/h3&gt;
&lt;p&gt;注意，没说输入的数据是按顺序的，虽然样例是按顺序的。这一题的解法是用两个堆，天然拿出左半边最大的和右半边做小的，注意做好奇偶处理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq

class MedianFinder:

    def __init__(self):
        self.max_heap = []
        self.min_heap = []

    def addNum(self, num: int) -&amp;gt; None:
        # 偏小的最大堆：max_heap；偏大的最小堆：min_heap
        heapq.heappush(self.max_heap,-num)
        lower_biggest = -heapq.heappop(self.max_heap)
        heapq.heappush(self.min_heap,lower_biggest)
        # 要始终满足 len(max_heap) &amp;gt;= len(min_heap)最多只多 1 个
        # 关键点就在于来回倒腾的往往不是一个数，但是维持了数量关系
        if len(self.min_heap) &amp;gt; len(self.max_heap):
            upper_smallest = heapq.heappop(self.min_heap)
            heapq.heappush(self.max_heap,-upper_smallest)


    def findMedian(self) -&amp;gt; float:
        if len(self.max_heap) &amp;gt; len(self.min_heap):
            return -self.max_heap[0]
        return (-self.max_heap[0]+self.min_heap[0]) / 2.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;贪心 + 优先队列&lt;/h2&gt;
&lt;p&gt;有时候，我们需要一边扫描，一边动态加入候选，并且反复取当前最大/最小。&lt;/p&gt;
&lt;h3&gt;LC253 - 会议室 II&lt;/h3&gt;
&lt;p&gt;堆维护“现在占着资源的人”&lt;/p&gt;
&lt;p&gt;给一个安排时间的数组，每个会议时间包含开始和结束，返回所需会议室最小数量，也就是：
[[0,30],[5,10],[15,20]] -&amp;gt; 2。&lt;/p&gt;
&lt;p&gt;这一题，是通过最小堆+优先队列实现的，堆里的元素表示要占用到哪个时间点。这样，新元素如果大于最小堆的最早结束时间，说明可以重复使用这个会议室，我们移除堆顶然后入堆即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq

def minMeetingRooms(intervals): 
    if not intervals:
        return 0
    rooms = []
    intervals.sort(key = lambda x:x[0])
    heapq.heappush(rooms,intervals[0][1])

    for i in intervals[1:]:
        if i[0]&amp;gt;=rooms[0]:
            heapq.heappop(rooms)
        
        heapq.heappush(rooms,i[1])

    return len(rooms)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC871 - 最低加油次数&lt;/h3&gt;
&lt;p&gt;堆维护“过去留下来的备选资源”&lt;/p&gt;
&lt;p&gt;这一题的标准思路是，我们维持一个大根堆，用fuel表示最远能到哪里，按顺序扫描所有加油站，把所有”已经能到达的站“的油量放入一个大根堆。如果当前的油不够去下一站/终点，就从大根堆里面拿一个最大的油补上，每弹一次堆，表示加油一次。&lt;/p&gt;
&lt;p&gt;所以，这一题实际上是贪心用最大油量加，从而让加油量尽量少。为了实现这个贪心借助了堆结构。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minRefuelStops(self, target: int, startFuel: int, stations: list[list[int]]) -&amp;gt; int:
    pq = []
    fuel = startFuel
    i = 0
    n = len(stations)
    ans = 0
    while fuel &amp;lt; target:
        while i &amp;lt; n and stations[i][0] &amp;lt;= fuel:
            heapq.heappush(pq, -stations[i][1])
            i += 1
        if not pq:
            return -1
        fuel += -heapq.heappop(pq)
        ans += 1
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Python heapq 使用要点&lt;/h2&gt;
&lt;p&gt;这里把 Python 里最常用的几个点单独记一下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import heapq

pq = []
heapq.heappush(pq, 3)
heapq.heappush(pq, 1)
heapq.heappush(pq, 2)
top = pq[0]              # 堆顶最小值
x = heapq.heappop(pq)    # 弹出最小值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个高频注意点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;heapq&lt;/code&gt; 默认是小根堆&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heapq.heapify(nums)&lt;/code&gt; 是原地建堆，返回值是 &lt;code&gt;None&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;想模拟大根堆，通常存相反数&lt;/li&gt;
&lt;li&gt;处理链表节点、对象、元组时，要注意比较规则&lt;/li&gt;
&lt;li&gt;如果元组前几位可能相等，后面又是不可比较对象，需要补一个 &lt;code&gt;idx&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话记忆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Python 里优先队列几乎就是 list + heapq。
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;栈与队列设计题&lt;/h1&gt;
&lt;h2&gt;栈的设计题&lt;/h2&gt;
&lt;h3&gt;LC155 - 最小栈&lt;/h3&gt;
&lt;p&gt;核心是“普通栈存值 + 辅助栈同步最小值”。难点不在 API，而在于要不要让 &lt;code&gt;min_stack&lt;/code&gt; 和主栈等长。本文前面用的是最稳妥的等长写法。&lt;/p&gt;
&lt;h3&gt;LC225 - 用队列实现栈&lt;/h3&gt;
&lt;p&gt;核心是让队列模拟“最后进来的先出去”。最稳定的写法是双队列倒腾，把最后一个元素单独留下来当栈顶。&lt;/p&gt;
&lt;p&gt;设计题的核心不是套模板，而是想清楚“我要额外维护什么信息”。&lt;/p&gt;
&lt;p&gt;这两题分别对应：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LC155&lt;/code&gt;：普通栈 + 同步最小值栈&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LC225&lt;/code&gt;：用两个队列模拟“最后进入的先出来”&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;队列的设计题&lt;/h2&gt;
&lt;h3&gt;LC232 - 用栈实现队列&lt;/h3&gt;
&lt;p&gt;核心是让一个栈负责输入、另一个栈负责输出。只有在输出栈为空时，才把输入栈整体倒过去。&lt;/p&gt;
&lt;h3&gt;LC622 - 设计循环队列&lt;/h3&gt;
&lt;p&gt;核心是循环数组 + 取模。建议统一采用“浪费一个位置”的写法，这样空和满的判断最清楚。&lt;/p&gt;
&lt;h3&gt;LC641 - 设计循环双端队列&lt;/h3&gt;
&lt;p&gt;比 LC622 多的只是“两头都能操作”，本质仍然是循环数组和指针含义的统一。&lt;/p&gt;
&lt;p&gt;队列设计题常见就三类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普通队列：先进先出&lt;/li&gt;
&lt;li&gt;循环队列：用取模节省空间&lt;/li&gt;
&lt;li&gt;双端队列：两头都能插入和删除&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这类题一定要先把指针含义定死，不然特别容易写着写着把自己绕晕。最推荐的定义方式是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;front&lt;/code&gt; 指向队头元素&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rear&lt;/code&gt; 指向队尾后一个空位&lt;/li&gt;
&lt;li&gt;空队列：&lt;code&gt;front == rear&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;满队列：浪费一个位置，判断 &lt;code&gt;(rear + 1) % capacity == front&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;栈与队列题目的分类判断&lt;/h1&gt;
&lt;h2&gt;一看到括号匹配、消消乐、撤销操作，就想栈&lt;/h2&gt;
&lt;p&gt;因为这类题都在维护“最近一个还没处理完的状态”。括号要和最近的左括号配对，消消乐要和最近的字符比，撤销也总是撤销最近一步。&lt;/p&gt;
&lt;h2&gt;一看到层序遍历、按顺序处理、最短步数，就想队列&lt;/h2&gt;
&lt;p&gt;因为队列天然按顺序推进，而 BFS 又天然按层扩散。凡是“从起点最少几步到终点”“一轮一轮蔓延”的题目，优先就该想到队列。&lt;/p&gt;
&lt;h2&gt;一看到下一个更大元素、找左右边界，就想单调栈&lt;/h2&gt;
&lt;p&gt;这类题本质都在找“第一个打破单调性的位置”。只要当前元素一来，前面一批元素的答案就能被结算，这就是单调栈最典型的信号。&lt;/p&gt;
&lt;h2&gt;一看到滑动窗口最大值、窗口最小值，就想单调队列&lt;/h2&gt;
&lt;p&gt;因为窗口在移动，而我们又需要实时拿到窗口最值。暴力 &lt;code&gt;max/min&lt;/code&gt; 会超时，于是就要用单调队列维护窗口内部的有效候选。&lt;/p&gt;
&lt;h2&gt;一看到动态最值、Top K、多路归并，就想优先队列&lt;/h2&gt;
&lt;p&gt;堆的关键词不是“排序”，而是“动态维护”。候选集一直在变，但每一步只要其中一个最值，这时优先队列就很自然。&lt;/p&gt;
&lt;h1&gt;栈与队列常见模板&lt;/h1&gt;
&lt;h2&gt;普通栈模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;stack = []

for x in nums:
    stack.append(x)

while stack:
    x = stack.pop()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;普通队列模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

q = deque([start])

while q:
    cur = q.popleft()
    for nxt in neighbors(cur):
        q.append(nxt)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;双端队列模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

dq = deque()
dq.append(x)
dq.appendleft(y)
dq.pop()
dq.popleft()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;括号匹配模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pairs = {&apos;)&apos;: &apos;(&apos;, &apos;]&apos;: &apos;[&apos;, &apos;}&apos;: &apos;{&apos;}
stack = []

for ch in s:
    if ch not in pairs:
        stack.append(ch)
    else:
        if not stack or stack.pop() != pairs[ch]:
            return False

return not stack
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;单调栈模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;stack = []

for i, x in enumerate(nums):
    while stack and nums[stack[-1]] &amp;lt; x:
        idx = stack.pop()
        # 结算 idx
    stack.append(i)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;单调队列模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

q = deque()

for i, x in enumerate(nums):
    while q and nums[q[-1]] &amp;lt;= x:
        q.pop()
    q.append(i)

    if q[0] &amp;lt;= i - k:
        q.popleft()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;优先队列模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import heapq

pq = []

for x in nums:
    heapq.heappush(pq, x)

while pq:
    x = heapq.heappop(pq)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;栈与队列问题总结&lt;/h1&gt;
&lt;p&gt;这一篇内容看起来很多，但其实核心只有几句话：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;栈解决最近、嵌套、回退&lt;/li&gt;
&lt;li&gt;队列解决顺序推进、层序扩散、最短步数&lt;/li&gt;
&lt;li&gt;单调栈解决“第一个更大/更小”和边界&lt;/li&gt;
&lt;li&gt;单调队列解决滑动窗口最值&lt;/li&gt;
&lt;li&gt;优先队列解决动态候选集里的最值问题&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果要把整篇再压缩成一句话，那就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;不要先背题，而是先判断题目需要维护哪一种顺序。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当你开始能从“维护顺序”的角度看题时，栈、队列、单调栈、单调队列、优先队列这几个专题就会越来越像一家人，而不是零散的很多模板。&lt;/p&gt;
</content:encoded></item><item><title>算法总结-递归</title><link>https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E9%80%92%E5%BD%92/</link><guid isPermaLink="true">https://owen571.top/posts/study/%E7%AE%97%E6%B3%95%E9%A2%98/%E7%AE%97%E6%B3%95%E6%80%BB%E7%BB%93-%E9%80%92%E5%BD%92/</guid><description>总结汇总一下递归技巧。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;递归的核心理解&lt;/h1&gt;
&lt;h2&gt;什么是递归&lt;/h2&gt;
&lt;p&gt;递归问题是指子问题和最终问题相似时，且子问题容易解决时，可以先&lt;strong&gt;递&lt;/strong&gt;到子问题，然后&lt;strong&gt;归&lt;/strong&gt;到原问题来求解。&lt;/p&gt;
&lt;p&gt;更直白地说，递归就是“函数自己调用自己”。但是刷题里真正重要的不是这个定义，而是要能看出来：当前问题能不能拆成一个或多个和原问题形式相同、规模更小的问题。&lt;/p&gt;
&lt;p&gt;比如求链表长度：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def length(head):
    if not head:
        return 0
    return 1 + length(head.next)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个函数的意思不是“我脑子里一层一层模拟调用栈”，而是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;当前链表长度 = 1 + 后面链表的长度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以写递归最重要的习惯是：相信递归函数已经能解决子问题。我们只需要想清楚当前层要做什么，以及什么时候停止。&lt;/p&gt;
&lt;h2&gt;递归三要素&lt;/h2&gt;
&lt;p&gt;递归一般可以拆成三个问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 递归函数的定义是什么？
2. 递归出口是什么？
3. 当前层如何利用子问题结果？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一点最重要。很多递归写乱，本质上不是不会写代码，而是一开始没有定义清楚函数到底返回什么、负责什么。&lt;/p&gt;
&lt;p&gt;比如二叉树最大深度：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxDepth(root):
    if not root:
        return 0
    return max(maxDepth(root.left), maxDepth(root.right)) + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里递归函数的定义是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;maxDepth(root) 返回以 root 为根节点的树的最大深度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有了这个定义后，代码就自然了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;当前树最大深度 = 左子树最大深度 和 右子树最大深度 的较大值 + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以以后写递归时，可以先写一句中文定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;这个函数接收什么？
这个函数返回什么？
这个函数处理的是哪一段/哪一棵树/哪一个状态？
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;递归函数的定义方式&lt;/h2&gt;
&lt;p&gt;递归函数通常有几种定义方式。&lt;/p&gt;
&lt;p&gt;第一种，定义为“处理某个结构”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(root):
    # 处理以 root 为根的整棵树
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;二叉树题最常见，比如最大深度、翻转二叉树、判断相同的树。&lt;/p&gt;
&lt;p&gt;第二种，定义为“处理某个区间”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(left, right):
    # 处理 [left, right] 这一段
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分治题、构造树、归并排序、快排经常这样写。&lt;/p&gt;
&lt;p&gt;第三种，定义为“从某个位置开始处理”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(index):
    # 处理从 index 开始的后续问题
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;组合、切割、字符串匹配、动态规划递归版经常这样写。&lt;/p&gt;
&lt;p&gt;第四种，定义为“当前路径/当前选择状态”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def backtrack(path, used):
    # 当前已经选了 path，used 表示哪些元素用过
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;排列、N皇后、数独、分桶问题经常这样写。&lt;/p&gt;
&lt;p&gt;第五种，定义为“两个对象之间的关系”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(p, q):
    # 判断 p 和 q 之间是否满足某种关系
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如对称二叉树、相同的树、最长公共子序列、编辑距离。&lt;/p&gt;
&lt;p&gt;递归函数定义得越准，后面的出口和递推关系就越容易写。&lt;/p&gt;
&lt;h2&gt;递归出口&lt;/h2&gt;
&lt;p&gt;递归出口就是最小子问题，也就是不用再继续拆的问题。没有递归出口，就会无限递归。&lt;/p&gt;
&lt;p&gt;常见出口有几类。&lt;/p&gt;
&lt;p&gt;结构为空：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if not root:
    return 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;区间非法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if left &amp;gt; right:
    return None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;位置到头：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if index == len(nums):
    return
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;目标达成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if target == 0:
    ans.append(path[:])
    return
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;状态已经算过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if state in memo:
    return memo[state]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归出口要和函数定义保持一致。比如函数定义是“返回链表长度”，空链表就应该返回 &lt;code&gt;0&lt;/code&gt;；如果函数定义是“返回是否存在路径”，走到目标就应该返回 &lt;code&gt;True&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里最容易错的是出口返回值。出口不是随便 return，而是要返回一个能被上一层正确使用的值。&lt;/p&gt;
&lt;h2&gt;递归返回值&lt;/h2&gt;
&lt;p&gt;递归函数可以有返回值，也可以没有返回值。&lt;/p&gt;
&lt;p&gt;有返回值时，通常是子问题的答案要交给上一层使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxDepth(root):
    if not root:
        return 0
    left = maxDepth(root.left)
    right = maxDepth(root.right)
    return max(left, right) + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有返回值时，通常是靠外部变量或者参数里的 &lt;code&gt;path&lt;/code&gt; 收集答案：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def backtrack(start):
    ans.append(path[:])
    for i in range(start, len(nums)):
        path.append(nums[i])
        backtrack(i + 1)
        path.pop()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以可以这样判断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;如果当前层需要子问题结果，就让递归函数 return。
如果只是枚举所有可能并收集答案，可以不 return，用 path 和 ans。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，回溯也可以有返回值，比如搜索是否存在一条路径时，找到后直接返回 &lt;code&gt;True&lt;/code&gt;，可以提前剪枝。&lt;/p&gt;
&lt;h2&gt;递归前序位置与后序位置&lt;/h2&gt;
&lt;p&gt;递归里经常说前序位置、后序位置，其实就是“递归调用前做事”还是“递归调用后做事”。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(root):
    if not root:
        return

    # 前序位置：刚进入当前节点
    dfs(root.left)
    # 中序位置：左子树处理完，右子树还没处理
    dfs(root.right)
    # 后序位置：左右子树都处理完
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前序位置适合自顶向下传递信息，比如当前路径和、当前深度、当前选择。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;path.append(root.val)
dfs(root.left)
dfs(root.right)
path.pop()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后序位置适合自底向上汇总信息，比如树的高度、节点数量、是否平衡、最大路径和。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;left = dfs(root.left)
right = dfs(root.right)
return max(left, right) + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以简单记：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;要把信息带下去，用前序。
要从子树收结果，用后序。
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;递归调用栈&lt;/h2&gt;
&lt;p&gt;递归调用不是“魔法”，它本质上是系统帮我们维护了一个调用栈。每调用一次函数，就会把当前函数的局部变量、执行位置、参数保存起来，等子函数返回后再继续执行。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def f(n):
    if n == 0:
        return
    print(&quot;before&quot;, n)
    f(n - 1)
    print(&quot;after&quot;, n)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调用 &lt;code&gt;f(3)&lt;/code&gt; 的输出是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;before 3
before 2
before 1
after 1
after 2
after 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是“递”和“归”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;递：一路进入更小的问题
归：从最小问题开始一层一层返回
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以很多题不建议一开始就把每一层调用全部脑补完，这样很容易晕。更好的方式是先相信函数定义，再看当前层如何组合答案。&lt;/p&gt;
&lt;h2&gt;递归和循环的关系&lt;/h2&gt;
&lt;p&gt;递归和循环都能表达重复过程。&lt;/p&gt;
&lt;p&gt;循环更适合线性、明确次数的问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for i in range(n):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归更适合天然有层级结构、分支结构的问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dfs(root.left)
dfs(root.right)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如遍历数组，用循环更自然；遍历二叉树，用递归更自然。&lt;/p&gt;
&lt;p&gt;从本质上说，递归可以转成循环，只是需要我们自己维护栈：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stack = [root]
while stack:
    node = stack.pop()
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而递归就是系统帮我们维护这件事。所以递归代码通常更短、更贴合问题结构，但代价是会消耗调用栈空间，数据规模太大时可能爆栈。&lt;/p&gt;
&lt;h2&gt;递归、分治、回溯、动态规划的区别&lt;/h2&gt;
&lt;p&gt;这几个概念很容易混在一起。我的理解是：递归是一种写法，分治、回溯、动态规划是几种不同的问题思想，它们都可以用递归来实现。&lt;/p&gt;
&lt;p&gt;递归：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;函数自己调用自己。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分治：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;把一个大问题拆成几个互相独立的小问题，分别解决后合并。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;典型例子是归并排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;排序左半边 -&amp;gt; 排序右半边 -&amp;gt; 合并两个有序数组
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;回溯：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;在一棵选择树上做选择，走不通就撤销选择，换下一条路。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;典型例子是全排列、组合、N皇后。&lt;/p&gt;
&lt;p&gt;动态规划：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;有重复子问题，并且可以通过保存子问题结果避免重复计算。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归版动态规划通常叫记忆化搜索。&lt;/p&gt;
&lt;p&gt;可以用一个判断方式区分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;子问题互相独立，最后合并：分治。
需要枚举选择，选完还要撤销：回溯。
子问题大量重复，需要缓存：动态规划。
只是顺着结构自然往下走：普通递归。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如二叉树最大深度，是普通结构递归；归并排序是分治递归；全排列是回溯递归；斐波那契加 memo 是记忆化搜索。&lt;/p&gt;
&lt;h1&gt;结构递归：顺着数据结构往下走&lt;/h1&gt;
&lt;h2&gt;链表递归&lt;/h2&gt;
&lt;h3&gt;LC206 - 反转链表&lt;/h3&gt;
&lt;p&gt;正常而言，这一题可以使用简单的三指针翻转解决，但是既然本专栏是递归专题，肯定是用递归的方法来做。我们思考，如果我们需要翻转的链表，实际上就是需要一个函数每次返回 翻转后的链表的头结点 。这样，我们就能定义一个递归函数，自己调用自己来完成任务。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 定义函数为返回翻转后的链表的头结点
def reverseList(self, head: ListNode) -&amp;gt; ListNode:
    if not head or not head.next:
        return head
    new_head = self.reverseList(head.next)
    # 原本的head.next现在在末尾
    head.next.next = head
    # 断开原本链接防止成环
    head.next = None
    return new_head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这道题的递归思路稍微有点绕，而且时空也不优秀，但是麻雀虽小，武藏巨拳。&lt;/p&gt;
&lt;h3&gt;LC24 - 两两交换链表中的节点&lt;/h3&gt;
&lt;p&gt;在双指针专题中，我们写的就是递归写法，也是这类题目最简单的写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def swapPairs(self, head: ListNode) -&amp;gt; ListNode:
    if not head or not head.next:
        return head
    curr = head
    prev = None
    for _ in range(2):
        curr.next, prev, curr = prev,curr, curr.next
    head.next = swapPairs(curr)
    return prev
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC25 - K个一组翻转链表&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def reverseKGroup(self, head: ListNode,k:int) -&amp;gt; ListNode:
    # 如果剩余少于k个直接退出
    p = head
    for _ in range(k):
        if not p:
            return head
        p = p.next

    curr = head
    prev = None
    for _ in range(k):
        curr.next, prev, curr = prev,curr, curr.next
    head.next = reverseKGroup(curr,k)
    return prev
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC21 - 合并两个有序链表&lt;/h3&gt;
&lt;p&gt;双指针合并自然是最简单的方式，但是，我们也可以利用递归结构来代替指针移动。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def mergeTwoLists(list1: ListNode, list2: ListNode) -&amp;gt; ListNode:
    if not list1:
        return list2
    if not list2:
        return list1

    if list1.val &amp;lt;= list2.val:
        list1.next = mergeTwoLists(list1.next, list2)
        return list1
    else:
        list2.next = mergeTwoLists(list1, list2.next)
        return list2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;双指针：自己维护 p，一步步接节点。
递归：每一层只决定当前头节点是谁，后面的合并交给递归。&lt;/p&gt;
&lt;p&gt;不过这样做的话，空间复杂度更差了。一般两个链表的合并，我们还是直接用双指针。&lt;/p&gt;
&lt;h3&gt;LC23 - 合并K个升序链表&lt;/h3&gt;
&lt;p&gt;涉及到了k个升序链表合并，就必须要用递归去做了（其实也可以用堆）。为了让递归树尽量平衡，我们应该每次在中间断开拆解子问题。当只有两个链表的时候，自然而然使用合并两个升序链表。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def merge2Lists(list1:ListNode,list2:ListNode)-&amp;gt;ListNode:
    dummy = ListNode()
    p = dummy
    while list1 and list2:
        if list1.val&amp;lt;=list2.val:
            p.next = list1
            list1 = list1.next
        else:
            p.next = list2
            list2 = list2.next
        p = p.next
    p.next = list1 if list1 else list2
    return dummy.next

# 合并k个有序链表
def mergeKLists(lists:ListNode[ListNode])-&amp;gt;ListNode:
    k = len(lists)
    if k == 0:
        return None
    if k == 1:
        return lists[0]
    if k == 2:
        return merge2Lists(lists[0],lists[1])
    # 大于两个链表开始递归处理
    mid = k//2
    left = mergeKLists(lists[:mid])
    right = mergeKLists(lists[mid:])
    return merge2Lists(left,right)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC234 - 回文链表&lt;/h3&gt;
&lt;p&gt;这一题最简单的做法是找中点、（断开）、翻转、判断。但是，也可以使用递归的方式来做。&lt;/p&gt;
&lt;p&gt;我们知道，递归可以“反向”访问到链表的元素，因此我的思路是，先递归一路走到链表尾部，然后回来的时候，从后往前访问节点，同时用left从前往后走。如果这个过程left.val和right.val都相等，那就是回文链表。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def isPalindrome(self, head: ListNode) -&amp;gt; bool:
    left = head
    def dfs(right:ListNode) -&amp;gt; bool:
        nonlocal left
        if not right:
            return True
        # 这里我们要将最后失败的结果一路传回来，所以不能简单递归dfs(right.next)，还要return一个值
        if not dfs(right.next):
            return False
        # 递归完成，从最后一位往前看
        if left.val != right.val:
            return False
        left = left.next
        return True
    return dfs(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样写的话，空间上更差一点，但是好在不会破坏原本的链表的结构。&lt;/p&gt;
&lt;h2&gt;二叉树递归&lt;/h2&gt;
&lt;h3&gt;LC104 - 二叉树的最大深度&lt;/h3&gt;
&lt;p&gt;树本身就是递归定义的，所以很多题目树的题目都可以回归本质用递归做：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(root:TreeNode)-&amp;gt;int:
    def dfs(node):
        if not node:
            return 0
        return 1+max(dfs(node.left),dfs(node.right))
    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC111 - 二叉树的最小深度&lt;/h3&gt;
&lt;p&gt;这类问题代表的是多出口的递归，不一定要一直递归到底返回，以为本题要求的答案不一定会到叶子节点才结算。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val = 0, left = None,right = None):
        self.val = val
        self.left = left
        self.right = right 

def solution(root:TreeNode)-&amp;gt;int:
    def dfs(node):
        if not node:
            return 0
        if not node.left and not node.right:
            return 1
        if not node.left:
            return 1+dfs(node.right)
        if not node.right:
            return 1+dfs(node.left)
        return 1+min(dfs(node.left),dfs(node.right))
    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC226 - 翻转二叉树&lt;/h3&gt;
&lt;p&gt;递归返回的时候从下往上调转左右即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def invertTree(self, root: TreeNode) -&amp;gt; TreeNode:
    def dfs(node):
        if not node:
            return 
        dfs(node.left)
        dfs(node.right)
        # 递归完成，从后往前翻转
        node.left,node.right = node.right,node.left
    dfs(root)
    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC100 - 相同的树&lt;/h3&gt;
&lt;p&gt;代表两边同时进行递归判断的题目：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def isSameTree(self, p: TreeNode, q: TreeNode) -&amp;gt; bool:
    def dfs(p,q)-&amp;gt;bool:
        if not p and not q:
            return True
        if not p and q:
            return False
        if p and not q:
            return False
        if p.val!=q.val:
            return False
        return dfs(p.left,q.left) and dfs(p.right,q.right)
    return dfs(p,q)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC101 - 对称二叉树&lt;/h3&gt;
&lt;p&gt;两个指针同时进行递归判断，一个向左一个向右，值和形状应该始终保持一致。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def isSymmetric(self, root: TreeNode) -&amp;gt; bool:
    def dfs(p,q):
        if not p and not q:
            return True
        if not p and q:
            return False
        if p and not q:
            return False
        if p.val != q.val:
            return False
        return dfs(p.left,q.right) and dfs(p.right,q.left)
    if not root:
        return True
    return dfs(root.left,root.right)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC543 - 二叉树的直径&lt;/h3&gt;
&lt;p&gt;本题属于递归维持、返回的量，和题目要求的量不一样。但是递归可以保证一定可以考虑到最优解的情况（实际上就是遍历了），所以我们维持一个全局量来更新，然后dfs即可。&lt;/p&gt;
&lt;p&gt;放在本题，直接想二叉树直径很难，可以将问题降级为“从node出发的最大节点数”，只要知道了这个量，任意节点都可以看自己的左右孩子的这个量，来判断需不需要更新最大路径。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def diameterOfBinaryTree(self, root: TreeNode) -&amp;gt; int:
    # 递归函数为从node开始最长的节点数
    max_path = 0
    def dfs(node:TreeNode)-&amp;gt;int:
        nonlocal max_path
        if not node:
            return 0
        left_path = dfs(node.left)
        right_path = dfs(node.right)

        # 因为边数等于节点数少1，题目求的是边数，所以不用+1
        max_path = max(max_path,
        left_path+right_path)

        return 1 + max(left_path,right_path)
    
    dfs(root)
    return max_path
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC110 - 平衡二叉树&lt;/h3&gt;
&lt;p&gt;平衡二叉树是左右高度之差之中不大于1，对所有子树都有这约束。这道题是通过递归传递多种信息的典型。我们通过传递的量不仅要表示子树高度为多少，还要表达子树是否已经不平衡，于是不平衡传递-1，告诉上层也不用继续算了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def isBalanced(self, root: TreeNode) -&amp;gt; bool:
    def dfs(node: TreeNode) -&amp;gt; int:
        if not node:
            return 0

        left = dfs(node.left)
        if left == -1:
            return -1

        right = dfs(node.right)
        if right == -1:
            return -1

        if abs(left - right) &amp;gt; 1:
            return -1
        # 记录左右最大高度
        return max(left, right) + 1

    return dfs(root) != -1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC236 - 二叉树的最近公共祖先&lt;/h3&gt;
&lt;p&gt;同样是通过递归传递信息，这题需要传递的信息是“是否找到p/q”。如果找到了，就要返回自身。如果没找到，就不要返回。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -&amp;gt; TreeNode:
    if not root or root == p or root == q:
        return root
    left = self.lowestCommonAncestor(root.left,p,q)
    right = self.lowestCommonAncestor(root.right,p,q)

    # 如果都取到了非空值，就找到了
    if left and right:
        return root
    # 否则只上交一边
    return left if left else right
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC235 - 二叉搜索树的最近公共祖先&lt;/h3&gt;
&lt;p&gt;上一题的逻辑适用于所有树的情况，这一题既然已经是BST，就可以利用值的性质。即如果p、q都是左子树，则答案一定在左边；反之一定在右边；如果一左一右，则root就是答案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -&amp;gt; TreeNode:
    if p.val &amp;lt; root.val and q.val &amp;lt; root.val:
        return self.lowestCommonAncestor(root.left, p, q)

    if p.val &amp;gt; root.val and q.val &amp;gt; root.val:
        return self.lowestCommonAncestor(root.right, p, q)
    # 如果一左一右，则root就是答案
    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;二叉搜索树递归&lt;/h2&gt;
&lt;h3&gt;LC98 - 验证二叉搜索树&lt;/h3&gt;
&lt;p&gt;需要注意的是，BST是全局性质，因此递归过程中要维持一个上下界。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def isValidBST(self, root: TreeNode) -&amp;gt; bool:
    def dfs(lo, hi, node):
        if not node:
            return True
        if not lo &amp;lt; node.val &amp;lt; hi:
            return False
        return dfs(lo, min(hi, node.val), node.left) and dfs(max(lo, node.val), hi, node.right)

    return dfs(float(&apos;-inf&apos;), float(&apos;inf&apos;), root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC700 - 二叉搜索树中的搜索&lt;/h3&gt;
&lt;p&gt;正常的思路，注意递归的分支和返回即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def searchBST(self, root: TreeNode, val: int) -&amp;gt; TreeNode:
    def dfs(node) -&amp;gt; TreeNode:
        if not node:
            return None
        if node.val == val:
            return node
        elif node.val &amp;lt; val:
            return dfs(node.right)
        else:
            return dfs(node.left)
    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC701 - 二叉搜索树中的插入操作&lt;/h3&gt;
&lt;p&gt;带条件的递归，我们根据 BST 的大小关系一路递归到空位置，在空位置创建新节点；回溯时把插入后的子树逐层接回原树，最后返回根节点。&lt;/p&gt;
&lt;p&gt;说白了，这是一题修改子树的递归题，而不是以前那样的找结果的递归题，所以感觉上可能有点稍微的差距，倒是和链表有点相似，就是返回修改过的部分。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def insertIntoBST(self, root: TreeNode, val: int) -&amp;gt; TreeNode:
    def dfs(node):
        if not node:
            return TreeNode(val)
        # 根据值的位置选择要调整的半边
        if node.val &amp;lt; val:
            node.right = dfs(node.right)
        else:
            node.left = dfs(node.left)
        return node
    
    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC450 - 删除二叉搜索树中的节点&lt;/h3&gt;
&lt;p&gt;和上一题一样，需要调整子树。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def deleteNode(self, root: TreeNode, key: int) -&amp;gt; TreeNode:
    def dfs(node):
        if not node:
            return None

        # 找到需要调整的半边
        if key &amp;lt; node.val:
            node.left = dfs(node.left)
            return node

        if key &amp;gt; node.val:
            node.right = dfs(node.right)
            return node

        # 找到要删除的节点
        if not node.left:
            return node.right

        if not node.right:
            return node.left

        # 左右子树都存在：右子树顶上来，左子树接到右子树最小节点下面
        p = node.right
        while p.left:
            p = p.left
        p.left = node.left

        return node.right

    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC230 - 二叉搜索树中第K小的元素&lt;/h3&gt;
&lt;p&gt;直接中序遍历即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def kthSmallest(self, root: TreeNode, k: int) -&amp;gt; int:
    ans = None
    def dfs(node):
        nonlocal k,ans
        if not node or ans is not None:
            return
        dfs(node.left)
        k-=1
        if k==0:
            ans = node.val
            return
        dfs(node.right)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;N叉树递归&lt;/h2&gt;
&lt;h3&gt;LC559 - N叉树的最大深度&lt;/h3&gt;
&lt;p&gt;N叉树的结局方法，和二叉树其实差不多，用一个循环解决即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxDepth(self, root: &apos;Node&apos;) -&amp;gt; int:
    def dfs(node):
        if not node:
            return 0
        max_children = 0
        for i in node.children:
            max_children = max(max_children,dfs(i))
        return 1+max_children
    return dfs(root)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC589 - N叉树的前序遍历&lt;/h3&gt;
&lt;p&gt;公式递归。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def preorder(self, root: &apos;Node&apos;) -&amp;gt; list[int]:
    ans = []
    def dfs(node):
        if not node:
            return 
        ans.append(node.val)
        for child in node.children:
            dfs(child)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC590 - N叉树的后序遍历&lt;/h3&gt;
&lt;p&gt;公公又式式&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def postorder(self, root: &apos;Node&apos;) -&amp;gt; list[int]:
    ans = []
    def dfs(node):
        if not node:
            return 
        for child in node.children:
            dfs(child)
        ans.append(node.val)
    dfs(root)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;分治递归：把问题拆成左右两半&lt;/h1&gt;
&lt;h2&gt;分治的基本模板&lt;/h2&gt;
&lt;p&gt;分治递归的关键，不只是“拆开”，更是“怎么合并”。很多题递归本身不难，难的是左右子问题的答案如何拼回当前问题。&lt;/p&gt;
&lt;p&gt;一个标准的分治题，通常都可以先问自己四个问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 这个问题能不能拆成左右两半？
2. 左半边要返回什么信息？
3. 右半边要返回什么信息？
4. 当前层如何利用左右信息合并答案？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个基础模板如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def divide_and_conquer(left, right):
    # 递归出口
    if left == right:
        return 单个区间的答案

    mid = (left + right) // 2

    left_info = divide_and_conquer(left, mid)
    right_info = divide_and_conquer(mid + 1, right)

    # 合并左右子问题答案
    return merge(left_info, right_info)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果是排序类题目，&lt;code&gt;merge&lt;/code&gt; 真的就是合并有序结果；如果是统计类题目，&lt;code&gt;merge&lt;/code&gt; 还会顺手统计贡献；如果是区间最值类题目，&lt;code&gt;merge&lt;/code&gt; 则是在左右子区间答案的基础上，再考虑是否存在“跨中点”的情况。&lt;/p&gt;
&lt;h2&gt;归并排序&lt;/h2&gt;
&lt;h3&gt;LC912 - 排序数组&lt;/h3&gt;
&lt;p&gt;这一题就是大名鼎鼎的归并排序。每次切半，直到不能切为止，然后组装起来。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def merge(nums1,nums2):
    nums = []
    i, j = 0, 0
    while i&amp;lt;len(nums1) and j&amp;lt;len(nums2):
        if nums1[i]&amp;lt;=nums2[j]:
            nums.append(nums1[i])
            i+=1
        else:
            nums.append(nums2[j])
            j+=1
    nums.extend(nums1[i:] if i&amp;lt;len(nums1) else nums2[j:])
    return nums

def sortArray(nums: list[int]) -&amp;gt; list[int]:
    n = len(nums)
    if n&amp;lt;= 1:
        return nums
    mid = n//2
    left = sortArray(nums[:mid])
    right = sortArray(nums[mid:])
    return merge(left,right)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后面有很多题目会在同一个函数中解决问题，因此我们学着利用python的特性，把分解与归并排序操作放在函数体内的同一个函数merge_sort中，这样更清晰：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(nums:list):
    def merge_sort(left,right):
        # 注意要写退出条件
        if left==right:
            return [nums[left]]
        mid = (left + right) // 2
        left_arr = merge_sort(left,mid)
        right_arr = merge_sort(mid+1,right)

        # 到这里，拆分已经完成，开始归并
        # 归并的过程，就是两个有序数组合并了
        merged = []
        i, j = 0, 0
        while i&amp;lt;len(left_arr) and j&amp;lt;len(right_arr):
            if left_arr[i]&amp;lt;=right_arr[j]:
                merged.append(left_arr[i])
                i += 1
            else:
                merged.append(right_arr[j])
                j += 1
        merged.extend(left_arr[i:])
        merged.extend(right_arr[j:])
        return merged
    
    if not nums:
        return []

    return merge_sort(0,len(nums)-1)        
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC148 - 排序链表&lt;/h3&gt;
&lt;p&gt;同样的归并排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def merge(list1:ListNode,list2:ListNode)-&amp;gt;ListNode:
    dummy = ListNode()
    p = dummy
    while list1 and list2:
        if list1.val &amp;lt;= list2.val:
            p.next = list1
            list1 = list1.next
        else:
            p.next = list2
            list2 = list2.next
        p = p.next
    p.next = list1 if list1 else list2
    return dummy.next

def sortList(head: ListNode) -&amp;gt; ListNode:
    if not head or not head.next:
        return head
    slow, fast = head, head
    while fast.next and fast.next.next:
        fast = fast.next.next
        slow = slow.next
    second = slow.next
    slow.next = None
    left = sortList(head)
    right = sortList(second)
    return merge(left,right)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同理单函数版：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ListNode:
    def __init__(self, val = 0, next = None):
        self.val = val
        self.next = next

def solution(head:ListNode):
    def merge_sort(head:ListNode):
        # 只有一个节点或者空的时候不用切了
        if not head or not head.next:
            return head
        fast, slow = head, head
        while fast.next and fast.next.next:
            fast = fast.next.next
            slow = slow.next
        second = slow.next
        slow.next = None
        leftList = merge_sort(head)
        rightList = merge_sort(second)

        # 切分完毕，开始合并
        dummy = ListNode()
        p = dummy
        l, r = leftList, rightList
        while l and r:
            if l.val &amp;lt;= r.val:
                p.next = l
                l = l.next
            else:
                p.next = r
                r = r.next
            p = p.next
        p.next = l if l else r
        return dummy.next
    
    if not head:
        return None
    return merge_sort(head)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC23 - 合并K个升序链表&lt;/h3&gt;
&lt;p&gt;跟上一题基本没啥区别，降到两两合并即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def merge2Lists(list1:ListNode,list2:ListNode)-&amp;gt;ListNode:
    dummy = ListNode()
    p = dummy
    while list1 and list2:
        if list1.val&amp;lt;=list2.val:
            p.next = list1
            list1 = list1.next
        else:
            p.next = list2
            list2 = list2.next
        p = p.next
    p.next = list1 if list1 else list2
    return dummy.next

# 合并k个有序链表
def mergeKLists(lists:ListNode[ListNode])-&amp;gt;ListNode:
    k = len(lists)
    if k == 0:
        return None
    if k == 1:
        return lists[0]
    if k == 2:
        return merge2Lists(lists[0],lists[1])
    # 大于两个链表开始递归处理
    mid = k//2
    left = mergeKLists(lists[:mid])
    right = mergeKLists(lists[mid:])
    return merge2Lists(left,right)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;快速排序&lt;/h2&gt;
&lt;p&gt;快速排序是非常经典的板子，大部分自带排序函数底层都是快排实现的，因为其效率很高。&lt;/p&gt;
&lt;h3&gt;快排模板&lt;/h3&gt;
&lt;p&gt;快排的本质，就是通过一次 partition，把 pivot 放到最终位置；再递归处理 pivot 左右两边。&lt;/p&gt;
&lt;p&gt;先选一个 pivot，常见写法是默认选最后一个元素；
用 i 标记“小于等于 pivot 区域”的下一个写入位置；
用 j 从左到右扫描；
遇到 &amp;lt;= pivot 的数，就和 nums[i] 交换，然后 i 往后走；
最后把 pivot 和 nums[i] 交换。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def quick_sort(nums: list[int]) -&amp;gt; list[int]:
    def partition(left: int, right: int) -&amp;gt; int:
        pivot = nums[right]
        i = left

        for j in range(left, right):
            if nums[j] &amp;lt;= pivot:
                nums[i], nums[j] = nums[j], nums[i]
                i += 1

        nums[i], nums[right] = nums[right], nums[i]
        return i

    def dfs(left: int, right: int) -&amp;gt; None:
        if left &amp;gt;= right:
            return

        pivot_idx = partition(left, right)

        dfs(left, pivot_idx - 1)
        dfs(pivot_idx + 1, right)

    dfs(0, len(nums) - 1)
    return nums
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC215 - 数组中的第K个最大元素&lt;/h3&gt;
&lt;p&gt;利用快速选择的性质，可以在不用完全排序前找到第k大的元素，这个方法也叫快速选择排序，可以在O(n)内解决这个问题。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def findKthLargest(nums: list[int], k: int) -&amp;gt; int:  
    target = len(nums) - k

    # 将最后一个元素作为枢纽，返回正确位置
    def partition(left,right):
        pivot = nums[right]
        i = left
        for j in range(left,right):
            if nums[j] &amp;lt;= pivot:
                nums[i],nums[j] = nums[j], nums[i]
                i += 1
        nums[i], nums[right] = nums[right], nums[i]
        return i


    left = 0
    right = len(nums) - 1

    def quick_select(left: int, right: int) -&amp;gt; int:
        pivot_idx = partition(left, right)

        if pivot_idx == target:
            return nums[pivot_idx]

        if pivot_idx &amp;lt; target:
            return quick_select(pivot_idx + 1, right)
        else:
            return quick_select(left, pivot_idx - 1)

    return quick_select(0, len(nums) - 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然而，这样写，力扣上会时间超限。这就很难受了，因为快选平均才能On，最坏要On方。我们有时候会加入随机来保证枢纽随便选：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 随机选枢纽
random_idx = random.randint(left,right)
nums[random_idx], nums[right] = nums[right], nums[random_idx]
pivot = nums[right]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是，这样还是会超时。这个写法在有大量重复元素的时候会发生退化。最好的方式，是把&amp;lt;=和else的两路划分，变成三路划分，如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import random

def findKthLargest(nums: list[int], k: int) -&amp;gt; int:
    target = k - 1  # 降序后的第 k 大下标

    def quick_select(left: int, right: int) -&amp;gt; int:
        pivot = nums[random.randint(left, right)]

        lt = left      # nums[left:lt] &amp;gt; pivot
        i = left       # nums[lt:i] == pivot
        gt = right     # nums[gt+1:right+1] &amp;lt; pivot

        while i &amp;lt;= gt:
            if nums[i] &amp;gt; pivot:
                nums[lt], nums[i] = nums[i], nums[lt]
                lt += 1
                i += 1
            elif nums[i] &amp;lt; pivot:
                nums[i], nums[gt] = nums[gt], nums[i]
                gt -= 1
            else:
                i += 1

        # 现在：
        # [left, lt - 1] 都 &amp;gt; pivot
        # [lt, gt] 都 == pivot
        # [gt + 1, right] 都 &amp;lt; pivot

        if target &amp;lt; lt:
            return quick_select(left, lt - 1)
        elif target &amp;gt; gt:
            return quick_select(gt + 1, right)
        else:
            return nums[target]

    return quick_select(0, len(nums) - 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没错，这就是经典的荷兰国旗问题啊，三路划分让快排可以有效处理很多重复元素，直接把lt到gt之间的元素忽略掉。&lt;/p&gt;
&lt;h2&gt;二分递归&lt;/h2&gt;
&lt;h3&gt;LC704 - 二分查找&lt;/h3&gt;
&lt;p&gt;经典二分查找，我们先直接写二分查找的板子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def search(self, nums: list[int], target: int) -&amp;gt; int:
    # 小于target往右找
    left, right = 0, len(nums)
    while left&amp;lt;right:
        mid = (left+right)//2
        if nums[mid]&amp;lt;target:
            left = mid + 1
        else:
            right = mid
    if left &amp;lt;len(nums) and nums[left] == target:
        return left
    else:
        return -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是放在了二分递归的板子里，是为了告诉你，这题也是非常符合递归的题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def binary_search(nums, target):
    def dfs(left, right):
        if left &amp;gt; right:
            return -1

        mid = (left + right) // 2

        if nums[mid] == target:
            return mid
        elif nums[mid] &amp;lt; target:
            return dfs(mid + 1, right)
        else:
            return dfs(left, mid - 1)

    return dfs(0, len(nums) - 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本质上和迭代写法等效，就是去两边找，但是迭代的写法更容易理解，且没有空间消耗，所以尽量写迭代吧。&lt;/p&gt;
&lt;h3&gt;LC35 - 搜索插入位置&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def searchInsert(self, nums: list[int], target: int) -&amp;gt; int:
    left, right = 0, len(nums)
    while left&amp;lt;right:
        mid = (left + right)//2
        if nums[mid]&amp;lt;target:
            left = mid + 1
        else:
            right = mid
    return left
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC69 - x的平方根&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def mySqrt(self, x: int) -&amp;gt; int:
    left, right = 0, x
    while left &amp;lt; right:
        mid = (left + right) // 2
        if mid*mid &amp;lt; x:
            left = mid + 1
        else:
            right = mid
    if left*left == x:
        return left
    return left-1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC50 - Pow(x, n)&lt;/h3&gt;
&lt;p&gt;这一题，是运用了快速幂的思想，将时间复杂度从O(n)降低到了O(logn)：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def myPow(x: float, n: int) -&amp;gt; float:
    def dfs(n: int) -&amp;gt; float:
        if n == 0:
            return 1

        half = dfs(n // 2)

        if n % 2 == 0:
            return half * half
        else:
            return half * half * x

    if n &amp;gt;= 0:
        return dfs(n)
    else:
        return 1 / dfs(-n)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;分治统计&lt;/h2&gt;
&lt;h3&gt;LC169 - 多数元素&lt;/h3&gt;
&lt;p&gt;本题属于答案一定在左或右，需要二次判断左右给出的信息。&lt;/p&gt;
&lt;p&gt;多数元素的最优解法是投票法，我们让相同加票数，不同减票数，最后剩下的候选人就是多数元素。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def majorityElement(self, nums: List[int]) -&amp;gt; int:
    candidate = None
    count = 0

    for num in nums:
        if count == 0:
            candidate = num
            count = 1
        elif num == candidate:
            count += 1
        else:
            count -= 1

    return candidate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过就当是锻炼思维，这一题也是可以用分治法来做的。分治的思路是递归左半边的多数元素、右半边的多数元素，如果结果相同就直接返回，否则就在当前区间内分别统计它们出现次数，返回次数更多的那个。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def majorityElement(nums: list[int]) -&amp;gt; int:
    def count_in_range(target: int, left: int, right: int) -&amp;gt; int:
        count = 0
        for i in range(left, right + 1):
            if nums[i] == target:
                count += 1
        return count

    def dfs(left: int, right: int) -&amp;gt; int:
        if left == right:
            return nums[left]

        mid = (left + right) // 2

        left_major = dfs(left, mid)
        right_major = dfs(mid + 1, right)

        if left_major == right_major:
            return left_major

        left_count = count_in_range(left_major, left, right)
        right_count = count_in_range(right_major, left, right)

        return left_major if left_count &amp;gt; right_count else right_major

    return dfs(0, len(nums) - 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC53 - 最大子数组和&lt;/h3&gt;
&lt;p&gt;本题属于答案可能在左、右或跨中点的归并题。&lt;/p&gt;
&lt;p&gt;这一题，每个数字要么接着前面的结果继续，要么另起炉灶，所以很容易就能想到动态规划来做。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxSubArray(self, nums: list[int]) -&amp;gt; int:
    # 我们让dp[i]表示以i结尾的最大数组和
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    for i in range(1,n):
        dp[i] = max(dp[i-1]+nums[i],nums[i])
    return max(dp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是最好的方法了，但是，这题同样可以使用分治统计来做。最大子数组只有三种可能，要么最大子数组完全在左半边，要么完全在右半边，要么跨过中点。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxSubArray(nums: list[int]) -&amp;gt; int:
    def dfs(left: int, right: int) -&amp;gt; int:
        if left == right:
            return nums[left]

        mid = (left + right) // 2

        left_max = dfs(left, mid)
        right_max = dfs(mid + 1, right)

        left_sum = float(&quot;-inf&quot;)
        curr = 0
        for i in range(mid, left - 1, -1):
            curr += nums[i]
            left_sum = max(left_sum, curr)

        right_sum = float(&quot;-inf&quot;)
        curr = 0
        for i in range(mid + 1, right + 1):
            curr += nums[i]
            right_sum = max(right_sum, curr)

        cross_max = left_sum + right_sum

        return max(left_max, right_max, cross_max)

    return dfs(0, len(nums) - 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个方法的空间复杂度logn，时间复杂度nlogn。&lt;/p&gt;
&lt;p&gt;然后，这个递归仍然可以优化，让时间变成on。需要返回每个区间返回四个信息：区间总和sum、区间最大后缀和prefix、区间最大后缀和suffix、区间最大子数组和best。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxSubArray(nums: list[int]) -&amp;gt; int:
    def dfs(left: int, right: int):
        if left == right:
            x = nums[left]
            return x, x, x, x
            # total, prefix, suffix, best

        mid = (left + right) // 2

        l_sum, l_pre, l_suf, l_best = dfs(left, mid)
        r_sum, r_pre, r_suf, r_best = dfs(mid + 1, right)

        total = l_sum + r_sum
        prefix = max(l_pre, l_sum + r_pre)
        suffix = max(r_suf, r_sum + l_suf)
        best = max(l_best, r_best, l_suf + r_pre)

        return total, prefix, suffix, best

    return dfs(0, len(nums) - 1)[3]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;剑指Offer 51 - 数组中的逆序对&lt;/h3&gt;
&lt;p&gt;在数组中的两个数字，如果前面一个数字大于后面的数字，则这两个数字组成一个逆序对。输入一个数组，求出这个数组中的逆序对的总数。&lt;/p&gt;
&lt;p&gt;本题是归并的时候统计贡献，难度适合入门。合并的过程中不断判断left是否超过right，如果超过就是逆序。这里的i、j本身也就是合并有序数组的意思（本身这题就是归并排序的同时去做统计），然后一旦发现left[i]大于right[j]，那么left后面所有的数字都能和right[j]构成逆序对，所以可以直接加个数为len(left)-i。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def reversePairs(nums: list[int]) -&amp;gt; int:
    count = 0

    def merge_sort(arr: list[int]) -&amp;gt; list[int]:
        nonlocal count

        if len(arr) &amp;lt;= 1:
            return arr

        mid = len(arr) // 2
        left = merge_sort(arr[:mid])
        right = merge_sort(arr[mid:])

        merged = []
        i, j = 0, 0

        while i &amp;lt; len(left) and j &amp;lt; len(right):
            if left[i] &amp;lt;= right[j]:
                merged.append(left[i])
                i += 1
            else:
                # left[i] &amp;gt; right[j]
                # left[i:] 都能和 right[j] 构成逆序对
                count += len(left) - i
                merged.append(right[j])
                j += 1

        merged.extend(left[i:])
        merged.extend(right[j:])

        return merged

    merge_sort(nums)
    return count
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC315 - 计算右侧小于当前元素的个数&lt;/h3&gt;
&lt;p&gt;本题依然属于归并过程中统计贡献题，比上一题难度高一点，核心思想是，在归并排序合并两个有序区间时，统计右半边有多少个数已经被放到当前左半边元素前面。&lt;/p&gt;
&lt;p&gt;因为右半边的元素原本就在当前元素右侧。
如果某些右半边元素比左半边当前元素小，并且已经先被合并走了，那它们就应该计入答案。&lt;/p&gt;
&lt;p&gt;注意为了保留原始下标，我们不能只排序数字，而要排序(value,index)，本题还是归并排序。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def countSmaller(nums: list[int]) -&amp;gt; list[int]:
    n = len(nums)
    ans = [0] * n
    arr = [(num, i) for i, num in enumerate(nums)]

    def merge_sort(left: int, right: int) -&amp;gt; list[tuple[int, int]]:
        if left == right:
            return [arr[left]]

        mid = (left + right) // 2
        left_part = merge_sort(left, mid)
        right_part = merge_sort(mid + 1, right)

        merged = []
        i, j = 0, 0
        right_smaller_count = 0

        while i &amp;lt; len(left_part) and j &amp;lt; len(right_part):
            if right_part[j][0] &amp;lt; left_part[i][0]:
                merged.append(right_part[j])
                # 记录先归并右边的元素个数
                right_smaller_count += 1
                j += 1
            else:
                value, idx = left_part[i]
                # 归并左边时加上已经归并过的右侧的记数
                ans[idx] += right_smaller_count
                merged.append(left_part[i])
                i += 1

        # 归并后续剩余元素

        while i &amp;lt; len(left_part):
            value, idx = left_part[i]
            ans[idx] += right_smaller_count
            merged.append(left_part[i])
            i += 1

        while j &amp;lt; len(right_part):
            merged.append(right_part[j])
            j += 1

        return merged

    if nums:
        merge_sort(0, n - 1)

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有人可能会疑惑，只在归并左侧元素时更新 ans 足够吗？其实是足够的。只有这种情况会对答案造成贡献：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前元素在左半边；&lt;/li&gt;
&lt;li&gt;更小的元素在右半边；&lt;/li&gt;
&lt;li&gt;右半边元素先于它被合并。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而拆分过程中，元素最终会被切成一个一个的元素，不会经过这个判断的，也只有右侧最后一个元素，而这个元素右侧小于它的数字个数必定是0，所以是完全足够的。&lt;/p&gt;
&lt;h1&gt;选择递归：每一步做选择&lt;/h1&gt;
&lt;h2&gt;子集问题&lt;/h2&gt;
&lt;h3&gt;LC78 - 子集&lt;/h3&gt;
&lt;p&gt;子集，实际上是一个每个元素可选可不选的问题，我们可以直接进行按位置的dfs。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def subsets(self, nums: list[int]) -&amp;gt; list[list[int]]:
    ans = []
    path = []
    def dfs(i):
        if i == len(nums):
            ans.append(path[:])
            return
        # 选择
        path.append(nums[i])
        dfs(i+1)
        # 回溯
        path.pop()
        # 不选择
        dfs(i+1)
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除了这种递归方法之外，我们还可以遍历数组递归，以开头元素为基准dfs出所有情况。为了不回头看，用start来调整对应的位置即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def subsets(self, nums: list[int]) -&amp;gt; list[list[int]]:
    ans = []
    path = []
    def dfs(start):
        ans.append(path[:])
        for i in range(start,len(nums)):
            path.append(nums[i])
            dfs(i+1)
            path.pop()
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结一下，子集问题有两种常见递归视角。第一种是选/不选，每个元素都做一次二选一，答案在叶子节点收集。第二种是枚举下一个选择，从 start 开始向后选择一个元素加入 path，答案在每个递归节点收集。对于没有重复元素的 LC78，两种都可以；对于有重复元素的 LC90，for + start 写法更适合同层去重。&lt;/p&gt;
&lt;h3&gt;LC90 - 子集II&lt;/h3&gt;
&lt;p&gt;子集II与子集问题只有轻微不同，给的数组nums可能包含重复元素，但解集不能包含重复的子集，所以一个简单的想法，先排序，然后开始暴力求子集，并去重：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def subsetsWithDup(self, nums: list[int]) -&amp;gt; list[list[int]]:
    nums.sort()
    ans = set()
    path = []
    def dfs(i):
        if i == len(nums):
            ans.add(tuple(path[:]))
            return
        # 选择
        path.append(nums[i])
        dfs(i+1)
        # 回溯
        path.pop()
        # 不选择
        dfs(i+1)
    dfs(0)
    return [list(t) for t in ans]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是，这样写的效率很低，有很多次无用递归。如果学到了NSum对于去重的方法，那么这一题就不会这么生硬去重，我们排序后，对于每次选择同样元素开始dfs的，直接跳过。&lt;/p&gt;
&lt;h2&gt;组合问题&lt;/h2&gt;
&lt;h3&gt;LC77 - 组合&lt;/h3&gt;
&lt;p&gt;给定两个整数 n 和 k，返回范围 [1, n] 中所有可能的 k 个数的组合。&lt;/p&gt;
&lt;p&gt;换句话说，不重复的1-n，让你选长度为k的子集。这么说就明白了，跟子集问题几乎没区别：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def combine(self, n: int, k: int) -&amp;gt; list[list[int]]:
    path = []
    ans = []
    def dfs(start):
        if len(path) == k:
            ans.append(path[:])
            return
        for i in range(start,n+1):
            path.append(i)
            dfs(i+1)
            path.pop()
    dfs(1)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC39 - 组合总和&lt;/h3&gt;
&lt;p&gt;给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ，找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ，并以列表形式返回。你可以按 任意顺序 返回这些组合。&lt;/p&gt;
&lt;p&gt;candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同，则两种组合是不同的。&lt;/p&gt;
&lt;p&gt;抓住两点：无重复、可复选。其他的dfs的条件变一下就行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def combinationSum(self, candidates: list[int], target: int) -&amp;gt; list[list[int]]:
    path = []
    ans = []
    total = 0
    def dfs(start):
        nonlocal total
        if total &amp;gt; target:
            return 
        if total == target:
            ans.append(path[:])
            return
        for i in range(start,len(candidates)):
            path.append(candidates[i])
            total += candidates[i]
            dfs(i)
            path.pop()
            total -= candidates[i]
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这么写是对的，不过效率还是可以继续提升，参考剪枝的方案，我们依旧先排序，然后再判断目前是否已经超过target。如果超过了，都不用继续递归进去。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def combinationSum(self, candidates: list[int], target: int) -&amp;gt; list[list[int]]:
    candidates.sort()
    path = []
    ans = []

    def dfs(start: int, total: int):
        if total == target:
            ans.append(path[:])
            return

        for i in range(start, len(candidates)):
            if total + candidates[i] &amp;gt; target:
                break

            path.append(candidates[i])
            dfs(i, total + candidates[i])
            path.pop()

    dfs(0, 0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC40 - 组合总和II&lt;/h3&gt;
&lt;p&gt;组合总和II和I相比，多了每个数字仅能使用一次，然后少了数字不重复。因此，我们要对重复元素进行剪枝：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def combinationSum2(self, candidates: list[int], target: int) -&amp;gt; list[list[int]]:
    candidates.sort()
    path = []
    ans = []
    def dfs(start,total):
        if total == target:
            ans.append(path[:])
            return
        for i in range(start,len(candidates)):
            if i&amp;gt;start and candidates[i] == candidates[i-1]:
                continue
            if total + candidates[i] &amp;gt; target:
                break
            path.append(candidates[i]) 
            dfs(i+1,total+candidates[i])
            path.pop()
    dfs(0,0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里进行了两种剪枝，continue那里是去重剪枝，虽然这个数不能选，但是后面的可能可以选；而break是超过目标剪枝，因为已经排序完成了，如果超过target，后面肯定不能选了，直接break掉就行。&lt;/p&gt;
&lt;h3&gt;LC216 - 组合总和III&lt;/h3&gt;
&lt;p&gt;找出所有相加之和为 n 的 k 个数的组合，且满足下列条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只使用数字1到9&lt;/li&gt;
&lt;li&gt;每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次，组合可以以任何顺序返回。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;翻译一下，用1-9（没有重复元素），不能重复用。其实很简单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def combinationSum3(self, k: int, n: int) -&amp;gt; list[list[int]]:
    path = []
    ans = []
    def dfs(start,total):
        if total == n and len(path)==k:
            ans.append(path[:])
        for i in range(start,10):
            if total+i&amp;gt;n:
                break
            path.append(i)
            dfs(i+1,total+i)
            path.pop()
    dfs(1,0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;排列问题&lt;/h2&gt;
&lt;h3&gt;LC46 - 全排列&lt;/h3&gt;
&lt;p&gt;全排列和子集问题的差距在于，可以回头选元素了。所以也不用维护start，每次都从头找。但是要额外维持一个seen，防止复选。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def permute(self, nums: list[int]) -&amp;gt; list[list[int]]:
    n = len(nums)
    seen = [False]*n
    path = []
    ans = []
    def dfs():
        if len(path) == n:
            ans.append(path[:])
            return
        
        for i in range(n):
            if seen[i]:
                continue
            seen[i] = True
            path.append(nums[i])
            dfs()
            path.pop()
            seen[i] = False
    dfs()
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC47 - 全排列II&lt;/h3&gt;
&lt;p&gt;给定一个可包含重复数字的序列 nums ，按任意顺序 返回所有不重复的全排列。&lt;/p&gt;
&lt;p&gt;也就是说，我们依旧是要进行去重了。但是，每一层都要从头扫，因为任意没用过的数都可能被选，所以我们不能能用start去重了，要改一下思路。&lt;/p&gt;
&lt;p&gt;于是我们利用used去重，如果i-1的位置没有被使用，且i位置和i-1位置数字一样，那么这个数字也不能先用 -- 换句话说，就是相同数字之间必须保持按原本顺序使用！所以就不会出现颠倒的两种情况。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def permuteUnique(self, nums: list[int]) -&amp;gt; list[list[int]]:
    nums.sort()
    ans = []
    path = []
    used = [False] * len(nums)

    def dfs():
        if len(path) == len(nums):
            ans.append(path[:])
            return

        for i in range(len(nums)):
            if used[i]:
                continue

            if i &amp;gt; 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                continue

            used[i] = True
            path.append(nums[i])

            dfs()

            path.pop()
            used[i] = False

    dfs()
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;切割问题&lt;/h2&gt;
&lt;h3&gt;LC131 - 分割回文串&lt;/h3&gt;
&lt;p&gt;本题可以通过dfs，考虑到所有区间切分的情况，从而将所有符合条件的子区间加入到答案当中。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def partition(self, s: str) -&amp;gt; list[list[str]]:
    def ishuiwen(nums):
        i,j = 0, len(nums)-1
        while i&amp;lt;j:
            if nums[i] != nums[j]:
                return False
            i+=1
            j-=1
        return True
    
    ans = []
    path = []

    def dfs(start):
        if start == len(s):
            ans.append(path[:])
            return 
        
        for end in range(start,len(s)):
            sub = s[start:end+1]
            if not ishuiwen(sub):
                continue
            path.append(sub)
            dfs(end+1)
            path.pop()
            
    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看起来，挺像每个位置依次去试，保证能覆盖所有字串。（下一个从end+1开始查）。从 start 位置开始，枚举下一刀切在哪里。如果 s[start:i+1] 是回文，就把这一段加入 path，然后递归处理 i+1 后面的部分。当 start == len(s)，说明整个字符串切完了，得到一个合法方案。&lt;/p&gt;
&lt;p&gt;如果只用二重循环，得到的是“局部合法片段”，只有dfs到底，才能得到全局合法方案。&lt;/p&gt;
&lt;h3&gt;LC93 - 复原IP地址&lt;/h3&gt;
&lt;p&gt;这题同样是切分问题，判断每个数字是否在0-255之间，在的话才能加入列表，然后用.连接加入答案即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    path = []
    ans = []

    def dfs(start: int):
        if len(path) == 4:
            if start == len(s):
                ans.append(&quot;.&quot;.join(path))
            return

        for end in range(start, min(start + 3, len(s))):
            sub = s[start:end + 1]

            if len(sub) &amp;gt; 1 and sub[0] == &quot;0&quot;:
                break

            if int(sub) &amp;gt; 255:
                break

            path.append(sub)
            dfs(end + 1)
            path.pop()

    dfs(0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是其实细看代码，还是有许多需要注意的逻辑点。首先，我们长度控制在三位及以内，我们应该将这个条件写在end循环中比较好；另外直接转为int，可能会有前导0问题，所以遇到前导0多位数的情况就可以不用继续往后判断了，直接删掉break。&lt;/p&gt;
&lt;h2&gt;棋盘问题&lt;/h2&gt;
&lt;h3&gt;LC51 - N皇后&lt;/h3&gt;
&lt;p&gt;N皇后也是在试验每个皇后能放在哪，最终能放完所有皇后。但是与之前相比，变成了二维实验。&lt;/p&gt;
&lt;p&gt;当然，由于每一行最多只能放一个皇后，所以不用二重循环，用dfs(row)表示给第row行放皇后即可。&lt;/p&gt;
&lt;p&gt;一个典型的想法是维持visited数组，每次回溯。但是这样会更复杂一些，不如另一种方法优雅：我们直接用坐标关系，来判断是不是一个竖行、正对角线、副对角线，然后用集合来判断某竖、正负对角是否被用过即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solveNQueens(n: int) -&amp;gt; list[list[str]]:
    ans = []
    board = [[&quot;.&quot;] * n for _ in range(n)]

    cols = set()
    diag1 = set()  # row - col
    diag2 = set()  # row + col

    def dfs(row: int):
        if row == n:
            ans.append([&quot;&quot;.join(r) for r in board])
            return

        for col in range(n):
            if col in cols:
                continue
            if row - col in diag1:
                continue
            if row + col in diag2:
                continue

            board[row][col] = &quot;Q&quot;
            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)

            dfs(row + 1)

            board[row][col] = &quot;.&quot;
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)

    dfs(0)
    return ans

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC52 - N皇后II&lt;/h3&gt;
&lt;p&gt;N皇后2只要统计解决方案的个数即可，正好可以再练一次手。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def totalNQueens(self, n: int) -&amp;gt; int:
    cols = set()
    diag1 = set()
    diag2 = set()
    count = 0
    def dfs(row):
        nonlocal count
        if row == n:
            count += 1
        for col in range(n):
            if row-col in diag1:
                continue
            if row+col in diag2:
                continue
            if col in cols:
                continue
            diag1.add(row-col)
            diag2.add(row+col)
            cols.add(col)
            dfs(row+1)
            diag1.remove(row-col)
            diag2.remove(row+col)
            cols.remove(col)
    dfs(0)
    return count
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC37 - 解数独&lt;/h3&gt;
&lt;p&gt;和上一题类似，我们用两个集合，rows字典和cols字典。我们可以先将所有需要填写的位置用一个数组存起来，然后再dfs去填写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solveSudoku(self, board: list[list[str]]) -&amp;gt; None:
    rows = [set() for _ in range(9)]
    cols = [set() for _ in range(9)]
    boxes = [set() for _ in range(9)]
    # 先找出所有空位，然后dfs去填
    spaces = []

    for i in range(9):
        for j in range(9):
            if board[i][j] == &quot;.&quot;:
                spaces.append((i, j))
            else:
                num = board[i][j]
                rows[i].add(num)
                cols[j].add(num)
                # 还有3x3也要去重合
                boxes[(i // 3) * 3 + j // 3].add(num)

    def dfs(idx: int) -&amp;gt; bool:
        if idx == len(spaces):
            return True

        i, j = spaces[idx]
        box_idx = (i // 3) * 3 + j // 3

        for num in map(str, range(1, 10)):
            if num in rows[i]:
                continue
            if num in cols[j]:
                continue
            if num in boxes[box_idx]:
                continue

            board[i][j] = num
            rows[i].add(num)
            cols[j].add(num)
            boxes[box_idx].add(num)

            if dfs(idx + 1):
                return True

            board[i][j] = &quot;.&quot;
            rows[i].remove(num)
            cols[j].remove(num)
            boxes[box_idx].remove(num)

        return False

    dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;搜索路径问题&lt;/h2&gt;
&lt;h3&gt;LC79 - 单词搜索&lt;/h3&gt;
&lt;p&gt;搜索类问题通常需要向多个方向进行dfs，如果能搜到底就成立。单词搜索的开头不固定，所以我们就二重循环定起点，然后开始dfs。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def exist(self, board: list[list[str]], word: str) -&amp;gt; bool:
    m, n = len(board), len(board[0])
    # dfs(i,j,k)表示当前在ij，要匹配word[k]开始的后缀
    def dfs(i: int, j: int, k: int) -&amp;gt; bool:
        # 特别注意dfs的返回
        # 越界返回
        if i &amp;lt; 0 or i &amp;gt;= m or j &amp;lt; 0 or j &amp;gt;= n:
            return False
        # 非需要字母返回
        if board[i][j] != word[k]:
            return False
        # 长度达标返回True
        if k == len(word) - 1:
            return True

        # 标记，防止重复使用
        ch = board[i][j]
        board[i][j] = &quot;#&quot;

        found = (
            dfs(i - 1, j, k + 1)
            or dfs(i + 1, j, k + 1)
            or dfs(i, j - 1, k + 1)
            or dfs(i, j + 1, k + 1)
        )

        board[i][j] = ch
        return found

    # 多起点dfs
    # 本题不需要path，匹配完成就是满足的
    for i in range(m):
        for j in range(n):
            if dfs(i, j, 0):
                return True

    return False
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC200 - 岛屿数量&lt;/h3&gt;
&lt;p&gt;进入dfs的入口写在外面，dfs的次数就代表联通区域：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def numIslands(self, grid: list[list[str]]) -&amp;gt; int:
    if not grid:
        return 0

    m, n = len(grid), len(grid[0])
    ans = 0

    def dfs(i: int, j: int) -&amp;gt; None:
        # 走到水里或越界
        if i &amp;lt; 0 or i &amp;gt;= m or j &amp;lt; 0 or j &amp;gt;= n or grid[i][j] != &quot;1&quot;:
            return

        # dfs各种走四个方向
        grid[i][j] = &quot;0&quot;
        dfs(i - 1, j)
        dfs(i + 1, j)
        dfs(i, j - 1)
        dfs(i, j + 1)

    # 找到一块陆地，就dfs把附近的路都水淹掉（标记为0）
    for i in range(m):
        for j in range(n):
            if grid[i][j] == &quot;1&quot;:
                ans += 1
                dfs(i, j)

    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC695 - 岛屿的最大面积&lt;/h3&gt;
&lt;p&gt;求面积要将所有方向的dfs都加起来，然后在求岛模版上加一个最大面积判断即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def maxAreaOfIsland(self, grid: list[list[int]]) -&amp;gt; int:
    if not grid:
        return 0

    m, n = len(grid), len(grid[0])

    def dfs(i: int, j: int) -&amp;gt; int:
        if i &amp;lt; 0 or i &amp;gt;= m or j &amp;lt; 0 or j &amp;gt;= n or grid[i][j] != 1:
            return 0

        grid[i][j] = 0

        # 几个方向都走到头
        return (
            1
            + dfs(i - 1, j)
            + dfs(i + 1, j)
            + dfs(i, j - 1)
            + dfs(i, j + 1)
        )

    max_area = 0

    for i in range(m):
        for j in range(n):
            if grid[i][j] == 1:
                max_area = max(max_area, dfs(i, j))

    return max_area
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC130 - 被围绕的区域&lt;/h3&gt;
&lt;p&gt;这题也是多源 DFS，但思路要反过来。不要从每个 O 出发判断它是否被 X 包围，而是从边界上的 O 出发，把所有与边界连通的 O 标记为安全。因为只要一个 O 能连到边界，它所在的整个连通块就不能被翻转。最后遍历全图，仍然是 O 的位置说明无法连到边界，改成 X；被标记过的安全位置再改回 O。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solve(self, board: list[list[str]]) -&amp;gt; None:
    if not board or not board[0]:
        return

    m, n = len(board), len(board[0])

    def dfs(i: int, j: int) -&amp;gt; None:
        if i &amp;lt; 0 or i &amp;gt;= m or j &amp;lt; 0 or j &amp;gt;= n:
            return
        if board[i][j] != &quot;O&quot;:
            return

        board[i][j] = &quot;#&quot;

        dfs(i - 1, j)
        dfs(i + 1, j)
        dfs(i, j - 1)
        dfs(i, j + 1)

    # 从边界 O 出发，标记所有安全 O
    for i in range(m):
        dfs(i, 0)
        dfs(i, n - 1)

    for j in range(n):
        dfs(0, j)
        dfs(m - 1, j)

    # 没被标记的 O 是被包围的；# 是安全的
    for i in range(m):
        for j in range(n):
            if board[i][j] == &quot;O&quot;:
                board[i][j] = &quot;X&quot;
            elif board[i][j] == &quot;#&quot;:
                board[i][j] = &quot;O&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;dp的结果就从起点开始一直到终止条件，但是上面几题你可以看出来dp的设计很灵活，需要仔细体会。&lt;/p&gt;
&lt;h3&gt;LC417 - 太平洋大西洋水流问题&lt;/h3&gt;
&lt;h2&gt;分桶与划分问题&lt;/h2&gt;
&lt;h3&gt;LC698 - 划分为K个相等的子集&lt;/h3&gt;
&lt;p&gt;这一题和数组连续划分的区别在于，这一题要考虑任意子集划分，所以要使用的方法是分桶划分。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def canPartitionKSubsets(self, nums: list[int], k: int) -&amp;gt; bool:
    total = sum(nums)

    if total % k != 0:
        return False

    target = total // k
    nums.sort(reverse=True)

    if nums[0] &amp;gt; target:
        return False

    buckets = [0] * k

    def dfs(index: int) -&amp;gt; bool:
        if index == len(nums):
            return all(bucket == target for bucket in buckets)

        num = nums[index]

        for i in range(k):
            if buckets[i] + num &amp;gt; target:
                continue

            buckets[i] += num

            if dfs(index + 1):
                return True

            buckets[i] -= num

            # 如果当前数字放进空桶都失败了，放进其他空桶也等价
            if buckets[i] == 0:
                return False

        return False

    return dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个思想也很直接，我们从0号桶开始放，放不下了尝试后面的。如果都能放满，就返回True，否则这个方案就不行。剪枝点在于空桶都放不下的话，那直接False。&lt;/p&gt;
&lt;p&gt;另外桶划分中，最好让失败趁早发生，也就是说，我们用快排，先把最长的元素塞进桶，早失败早退出。&lt;/p&gt;
&lt;h3&gt;LC473 - 火柴拼正方形&lt;/h3&gt;
&lt;p&gt;写了上一题就知道，这一题实际上还是四桶问题。可以直接写写看。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def makesquare(self, matchsticks: list[int]) -&amp;gt; bool:
    matchsticks.sort(reverse=True)
    total = sum(matchsticks)
    if total % 4 != 0:
        return False
    target = total // 4
    buckets = [0] * 4

    # idx是火柴下标
    def dfs(idx):
        if idx == len(matchsticks):
            return all(bucket == target for bucket in buckets)
        matchstick = matchsticks[idx]

        # 遍历桶
        for i in range(4):
            # 放不下就下一个
            if buckets[i] + matchstick &amp;gt; target:
                continue

            # 放进去，就递归，如果递归到底返回True，这里就返回True
            buckets[i] += matchstick
            if dfs(idx + 1):
                return True

            # 回溯
            buckets[i] -= matchstick

            # 剪枝
            if buckets[i] == 0:
                return False
        return False    
        
    return dfs(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;状态递归：递归函数表示一个状态&lt;/h1&gt;
&lt;h2&gt;斐波那契与爬楼梯&lt;/h2&gt;
&lt;h3&gt;LC509 - 斐波那契数&lt;/h3&gt;
&lt;p&gt;斐波那切数和爬楼梯应该是很多人动态规划开始的地方。所以我们在这里也写一下dp的方案。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def fib(self, n: int) -&amp;gt; int:
    if n &amp;lt;= 1:
        return n
    dp = [0] * (n+1)
    dp[0] = 0
    dp[1] = 1
    for i in range(2,n+1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其实，dp就是打表的递归问题，从而避免计算重复问题。会写dp，自然也会写递归：dp初始状态就是递归出口，状态转移公式就是递归函数，直接变化如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def fib(self, n: int) -&amp;gt; int:
    def dfs(n):
        if n &amp;lt;= 1:
            return n
        return dfs(n-1) + dfs(n-2)
    return dfs(n)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归解法由于会重复计算，效率小于dp。为了不进行裸递归，我们可以使用python的缓存装饰器@cache，从而自动进行记忆化搜索。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def fib(self, n: int) -&amp;gt; int:
    @cache
    def dfs(n):
        if n &amp;lt;= 1:
            return n
        return dfs(n-1) + dfs(n-2)
    return dfs(n)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC70 - 爬楼梯&lt;/h3&gt;
&lt;p&gt;跟斐波那契几乎一样。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def climbStairs(self, n: int) -&amp;gt; int:
    @cache
    def dfs(n):
        if n &amp;lt;= 2:
            return n
        return dfs(n-1)+dfs(n-2)
    return dfs(n)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC746 - 使用最小花费爬楼梯&lt;/h3&gt;
&lt;p&gt;和上一题类似，但是这题是看上楼梯的价值。我们尝试递归。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def minCostClimbingStairs(self, cost: list[int]) -&amp;gt; int:
    # dfs表示到达下标n的最小费用
    @cache
    def dfs(n):
        if n &amp;lt;= 1:
            return 0
        return min(dfs(n-2)+cost[n-2],dfs(n-1)+cost[n-1])
    return dfs(len(cost))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;dp写法如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minCostClimbingStairs(self, cost: list[int]) -&amp;gt; int:
    n = len(cost)
    dp = [0] * (n+1)
    for i in range(2,n+1):
        dp[i] = min(dp[i-2]+cost[i-2],dp[i-1]+cost[i-1])
    return dp[n]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键在于，这题上完所有楼梯之后还要登顶，等跳出所有楼梯再结算而不是上到最后一级台阶。&lt;/p&gt;
&lt;h2&gt;网格路径&lt;/h2&gt;
&lt;h3&gt;LC62 - 不同路径&lt;/h3&gt;
&lt;p&gt;这类题目，同样最先想到的是二维dp，是二维dp的启蒙题。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def uniquePaths(self, m: int, n: int) -&amp;gt; int:
    dp = [[1]*n for _ in range(m)]
    # 只能向右或向下，初始化边界
    for i in range(1,m):
        dp[i][0] = 1
    for j in range(1,n):
        dp[0][j] = 1

    for i in range(1,m):
        for j in range(1,n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    
    return dp[m-1][n-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;dp的题本质上是记忆化递归，所以肯定也能写成递归形式。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def uniquePaths(self, m: int, n: int) -&amp;gt; int:
    @cache
    def dfs(i,j):
        if i == 0 or j == 0:
            return 1
        return dfs(i-1,j) + dfs(i,j-1)
    return dfs(m-1,n-1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC63 - 不同路径II&lt;/h3&gt;
&lt;p&gt;与 不同路径 对比，多了一个障碍物的设计，路径不能从障碍物来，同样也不能到障碍物。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def uniquePathsWithObstacles(self, obstacleGrid: list[list[int]]) -&amp;gt; int:
    m = len(obstacleGrid)
    n = len(obstacleGrid[0])
    # 这里要用0，因为不是全网格都默认1，所以还要初始化起点
    dp = [[0]*n for _ in range(m)]
    # 起点或终点本身是石头，无路可走
    if obstacleGrid[0][0] == 1 or obstacleGrid[-1][-1] == 1:
            return 0
    dp[0][0] = 1
    # 只能向右或向下，初始化边界
    for i in range(1,m):
        # 遇到障碍物直接此路不通
        if obstacleGrid[i][0] == 1:
            break
        dp[i][0] = 1
    for j in range(1,n):
        if obstacleGrid[0][j] == 1:
            break
        dp[0][j] = 1

    for i in range(1,m):
        for j in range(1,n):
            if obstacleGrid[i][j] == 0:
                dp[i][j] = dp[i-1][j] + dp[i][j-1]
    
    return dp[m-1][n-1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;效率一样的情况下，这一题记忆递归效率更高，注意递归的时候要先判断下标越界再访问下标。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def uniquePathsWithObstacles(self, obstacleGrid: list[list[int]]) -&amp;gt; int:
    m = len(obstacleGrid)
    n = len(obstacleGrid[0])
    @cache
    def dfs(i,j):
        if i &amp;lt; 0 or j &amp;lt; 0:
            return 0
        if obstacleGrid[i][j] == 1:
            return 0
        if i == 0 and j == 0:
            return 1
        return dfs(i-1,j) + dfs(i,j-1)
    return dfs(m-1,n-1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC64 - 最小路径和&lt;/h3&gt;
&lt;p&gt;同样往下或者往右走，我们用 dfs(i,j) 来求解位置i、j的最大小路径。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def minPathSum(self, grid: list[list[int]]) -&amp;gt; int:
    @cache
    def dfs(i,j):
        if i&amp;lt;0 or j&amp;lt;0 :
            return float(&apos;inf&apos;)
        if i == 0 and j == 0:
            return grid[0][0]
        return grid[i][j] + min(dfs(i-1,j),dfs(i,j-1))
    return dfs(len(grid)-1,len(grid[0])-1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;代码更简洁，但是要注意花费应该什么时候被计算，这是很重要的，跟收费楼梯又是不一样的逻辑，还有就是越界到底应该怎么返回、传入的下标。这些都是容易爆的地方。&lt;/p&gt;
&lt;h2&gt;背包递归&lt;/h2&gt;
&lt;h3&gt;0-1背包&lt;/h3&gt;
&lt;h3&gt;完全背包&lt;/h3&gt;
&lt;h3&gt;LC416 - 分割等和子集&lt;/h3&gt;
&lt;p&gt;分割成K个子集，是多桶问题，而这里的分割成两个等和子集，其实就是找出是不是有等于和一半的子集，可以将其理解为0-1背包，每个元素选或不选，最终能否达到target。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def canPartition(self, nums: list[int]) -&amp;gt; bool:
    total = sum(nums)
    if total % 2 != 0:
        return False
    
    target = total // 2
    
    # 将状态作为参数传入：i 表示当前下标，curr_sum 表示当前的累加和
    @cache
    def dfs(i, curr_sum):
        # 1. 成功条件：凑齐了！
        if curr_sum == target:
            return True
            
        # 2. 失败/终止条件：超重了，或者所有的数字都选完了
        if curr_sum &amp;gt; target or i == len(nums):
            return False
            
        # 3. 状态转移：选当前数字 OR 不选当前数字
        return dfs(i + 1, curr_sum + nums[i]) or dfs(i + 1, curr_sum)
        
    return dfs(0, 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一开始用nonlocal来做curr_sum，实际上可以直接写在函数体内。然后就是第一次写用了nonlocal的flag，来表示有没有找到，这样没办法及时剪枝，从而报了TLE。我们需要用dfs来判断当前位置、当前和开始，&lt;strong&gt;能不能找到和为target的划分&lt;/strong&gt; ， 用一个or连接，失败的条件是target或i超限，这是最好最干净的思路。&lt;/p&gt;
&lt;h3&gt;LC494 - 目标和&lt;/h3&gt;
&lt;p&gt;其实就是用dfs判断每一位是加还是减。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def findTargetSumWays(self, nums: list[int], target: int) -&amp;gt; int:
    @cache
    def dfs(i,total):
        if i == len(nums) and total == target:
            return 1
        if i == len(nums):
            return 0
        return dfs(i+1,total-nums[i]) + dfs(i+1,total+nums[i])
    return dfs(0,0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;字符串递归&lt;/h2&gt;
&lt;h3&gt;LC72 - 编辑距离&lt;/h3&gt;
&lt;p&gt;本题是经典的二维dp题，我们用dp(i,j)表示处理到了word1/2的位置（前面已经相等），但是有三种操作，分别是插入、删除、替换。删除之前需要i-1对上j，所以对应的操作是 dp[i-1][j] + 1；插入需要j-1位置对应上i，也就是 dp[i][j-1] + 1；替换需要i-1和j-1都对应上，也就是操作数 dp[i-1][j-1] + 1。我们取三者中的最小值，就能解决了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def minDistance(self, word1: str, word2: str) -&amp;gt; int:
    m, n = len(word1), len(word2)
    # dp[i][j] 代表 word1 中前 i 个字符，变换到 word2 中前 j 个字符，最短需要操作的次数
    dp = [[0] * (n+1) for _ in range(m+1)]
    # 基础情况
    for i in range(1,m+1):
        dp[i][0] = i
    for j in range(1,n+1):
        dp[0][j] = j

    for i in range(1,m+1):
        for j in range(1,n+1):
            if word1[i - 1] == word2[j - 1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(
                    dp[i-1][j] + 1,
                    dp[i][j-1] + 1,
                    dp[i-1][j-1] + 1
                )
    return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本题用dp比较好想。&lt;/p&gt;
&lt;h3&gt;LC1143 - 最长公共子序列&lt;/h3&gt;
&lt;p&gt;我们在一维中经常做到这样的题目，现在要求两个字符串的最长公共子序列，我们依旧可以直接二维dp。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def longestCommonSubsequence(self, text1: str, text2: str) -&amp;gt; int:
    # dp[i][j]为text1第i位和text2第j位的最长公共子串。转移方法是当text1[i]和text2[j]相等的时候，dp[i][j] = dp[i-1][j-1] + 1
    m = len(text1)
    n = len(text2)
    dp = [[0] * (n+1) for _ in range(m+1)]
    for i in range(1,m+1):
        for j in range(1,n+1):
            # Case1、字母相同，直接加到公共子序列
            if text1[i-1] == text2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                # 如果不相等，看看谁前进一格能让dp更大
                dp[i][j] = max(dp[i-1][j],dp[i][j-1])
    return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归解法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def longestCommonSubsequence(self, text1: str, text2: str) -&amp;gt; int:
    # 递归写法
    @cache
    def dfs(i,j):
        if i &amp;lt; 0 or j &amp;lt; 0 :
            return 0
        if text1[i] == text2[j]:
            return dfs(i-1,j-1)+1
        return max(dfs(i-1,j),dfs(i,j-1))
    return dfs(len(text1)-1,len(text2)-1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC115 - 不同的子序列&lt;/h3&gt;
&lt;p&gt;子序列问题，判断s子序列中出现t的个数。我们可以直接对s进行dfs，判断符合条件的子序列：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def numDistinct(s: str, t: str) -&amp;gt; int:
    m = len(s)
    n = len(t)
    path = []
    count = 0
    # 用dfs(i)表示第i位选不选，得到t的方案个数
    def dfs(i):
        nonlocal count
        if len(path) == n and &quot;&quot;.join(path) == t:
            count += 1
            return
        if i == m:
            return
        # 选
        path.append(s[i])
        dfs(i+1)
        path.pop()

        # 不选
        dfs(i+1)

    dfs(0)
    return count
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然而，无脑dfs，必然超时了。这一题其实也是二维dp做法，加一个cache。我们可以将 dp[i][j] 定义为 s 从下标 i 开始的子串，能够匹配出多少个 t 从下标 j 开始的子串。&lt;/p&gt;
&lt;p&gt;现在，我们来思考一下转移，这种问题我们要只考虑一个字母。首先，如果两个字母对上了，即 s[i] == t[j]，那么我们有两种选择，要么直接从这开始匹配，dp[i][j] = dp[i+1][j+1]，要么还是不选他，因为后面可能还有，所以是 dp[i][j] = dp[i][j+1]。至于不匹配，那就直接 dp[i][j] = dp[i+1][j+1]。（不过填表的过程需要你用当前dp看之前的dp，写成减号）。&lt;/p&gt;
&lt;p&gt;我们来试试按照递归的思路来做，从最后一位开始看。逻辑和上述一样，初始状态是j回退到了0则为1，然后s回退到0即为0&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

class Solution:
    def numDistinct(self, s: str, t: str) -&amp;gt; int:
        @cache
        def dfs(i,j):
            if j &amp;lt; 0: 
                return 1
            if i &amp;lt; 0:
                return 0
            if s[i] == t[j]:
                return dfs(i-1,j-1) + dfs(i-1,j)
            else:
                return dfs(i-1,j)
    
        return dfs(len(s)-1,len(t)-1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC10 - 正则表达式匹配&lt;/h3&gt;
&lt;p&gt;本题依旧是基于双指针的dp或者记忆化搜索。基础的规则，如果遇到相同的元素，我们 dp[i][j] = dp[i+1][j+1] ，这是很容易想到的。但是这一题多了.和*。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Solution:
    def isMatch(self, s: str, p: str) -&amp;gt; bool:
        m, n = len(s), len(p)
        # dp[i][j] 表示 s 的前 i 个字符，和 p 的前 j 个字符是否匹配
        dp = [[False] * (n + 1) for _ in range(m + 1)]
        
        # Base Case 1：两个空字符串，完美匹配
        dp[0][0] = True
        
        # Base Case 2：s 是空字符串，p 不是空字符串。
        # 只有当 p 是类似 &quot;a*b*c*&quot; 这种可以全部“消除”成空串的结构时，才能匹配
        for j in range(1, n + 1):
            if p[j-1] == &apos;*&apos;:
                # 如果遇到星号，它的状态等同于把前面那个字符也一起消除掉之前的状态
                dp[0][j] = dp[0][j-2]
                
        # 正向填表
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                # 情况 1：普通字符或者 &apos;.&apos;，直接看上一个状态
                if s[i-1] == p[j-1] or p[j-1] == &apos;.&apos;:
                    dp[i][j] = dp[i-1][j-1]
                
                # 情况 2：遇到星号
                elif p[j-1] == &apos;*&apos;:
                    # 动作 A：消除器（把 p 的末尾字符和 * 一起当空气）
                    # 状态继承自 dp[i][j-2]
                    erase = dp[i][j-2]
                    
                    # 动作 B：克隆器（前提：s 的最后一个字符必须和 p 中星号前面的字符匹配）
                    # 如果匹配，状态继承自 dp[i-1][j] (客人走了一个，规则还在)
                    clone = False
                    if s[i-1] == p[j-2] or p[j-2] == &apos;.&apos;:
                        clone = dp[i-1][j]
                        
                    dp[i][j] = erase or clone
                    
        return dp[m][n]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;简单来说，待匹配元素为0来初始化dp表，然后.是一定放行，*分为两种情况，消除前面一位或者让前面一位一直重复。&lt;/p&gt;
&lt;h2&gt;博弈递归&lt;/h2&gt;
&lt;h3&gt;LC486 - 预测赢家&lt;/h3&gt;
&lt;p&gt;从现在我们进入了博弈论 DP（Minimax 极小化极大算法），看代码的时候经常很难理解，因为这套递归中，隐藏了无缝的“视角切换”。之前的题，dfs主视角永远是自己、当前，而博弈论中，dfs变成一个高级模型，两人会轮流使用它得出自己的最优解。&lt;/p&gt;
&lt;p&gt;我们只用dfs关心自己的分减去对手分的正负，也就是自己的优势。打个比方，我方选择left的时候，对手采用最优解得到的相对我的分数是 dfs(left+1,right)，那么我想对于对手的最优解得到的分数增加就是 nums[left] - dfs(left+1, right)。&lt;/p&gt;
&lt;p&gt;所以，实际上我们在两种选择，都会考虑相对于对手的最优解我们的最优解，最终只要判断从这过区间开始，第一个开始选的人能不能让优势大于0，就可以解决这题了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def PredictTheWinner(self, nums: list[int]) -&amp;gt; bool:
    @cache
    def dfs(left: int, right: int) -&amp;gt; int:
        # 只剩一个可以拿
        if left == right:
            return nums[left]

        # dfs(left, right) 表示在区间 [left, right] 中，当前玩家相对于对手最多能领先多少分。
        take_left = nums[left] - dfs(left + 1, right)
        take_right = nums[right] - dfs(left, right - 1)

        return max(take_left, take_right)

    # 对手的优势大于0就输了
    return dfs(0, len(nums) - 1) &amp;gt;= 0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC877 - 石子游戏&lt;/h3&gt;
&lt;p&gt;这一题其实跟预测赢家一样，我们用dfs来表示对手可能产生的优势，按照相同方式求解。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

def stoneGame(self, piles: list[int]) -&amp;gt; bool:
    @cache
    def dfs(i,j):
        if i == j:
            return piles[i]
        
        take_left = piles[i] - dfs(i+1,j)
        take_right = piles[j] - dfs(i,j-1)

        return max(take_left,take_right)
    
    return dfs(0,len(piles)-1)  &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过，这一题可以直接用石子游戏题目结论秒杀，结论就是先手必胜。。直接return
True。&lt;/p&gt;
&lt;h1&gt;构造递归：递归地生成答案&lt;/h1&gt;
&lt;h2&gt;生成括号&lt;/h2&gt;
&lt;h3&gt;LC22 - 括号生成&lt;/h3&gt;
&lt;p&gt;dfs生成答案的过程中，我们还要保证括号有效，所以我们需要left和right来维持有效性，可以用nonlocal更改或者直接当参数传入。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def generateParenthesis(self, n: int) -&amp;gt; list[str]:
    ans = []
    path = []

    def dfs(left: int, right: int):
        if len(path) == 2 * n:
            ans.append(&quot;&quot;.join(path))
            return

        if left &amp;lt; n:
            path.append(&quot;(&quot;)
            dfs(left + 1, right)
            path.pop()

        if right &amp;lt; left:
            path.append(&quot;)&quot;)
            dfs(left, right + 1)
            path.pop()

    dfs(0, 0)
    return ans
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;构造二叉树&lt;/h2&gt;
&lt;h3&gt;LC105 - 从前序与中序遍历序列构造二叉树&lt;/h3&gt;
&lt;p&gt;树是天然递归结构，我用左右指针对准dfs的结果就行，dfs定义为完成构建的二叉树。同时，我们传入目前的前序和中序数组。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def buildTree(self, preorder: list[int], inorder: list[int]) -&amp;gt; TreeNode:
    if not preorder or not inorder:
        return None

    root_val = preorder[0]
    root = TreeNode(root_val)

    idx = inorder.index(root_val)
    left_size = idx

    root.left = self.buildTree(preorder[1:1 + left_size], inorder[:idx])
    root.right = self.buildTree(preorder[1 + left_size:], inorder[idx + 1:])

    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC106 - 从中序与后序遍历序列构造二叉树&lt;/h3&gt;
&lt;p&gt;跟上一题如出一辙，我们记得按照方式划分即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.left = left
        self.right = right
        self.val = val

def buildTree(self, inorder: list[int], postorder: list[int]) -&amp;gt; TreeNode:
    # 后序左右根，中序左根右
    if not postorder:
        return None
    root = TreeNode(postorder[-1])
    # 左侧长度为idx
    idx = inorder.index(postorder[-1])
    root.left = buildTree(inorder[:idx],postorder[:idx])
    root.right = buildTree(inorder[idx+1:],postorder[idx:-1])
    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC654 - 最大二叉树&lt;/h3&gt;
&lt;p&gt;这一题直接就把构造的过程告诉你了，照猫画虎即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def constructMaximumBinaryTree(self, nums: list[int]) -&amp;gt; TreeNode:
    if len(nums) == 0:
        return None
    max_num = max(nums)
    root = TreeNode(max_num)
    idx = nums.index(max_num)
    root.left = constructMaximumBinaryTree(nums[:idx])
    root.right = constructMaximumBinaryTree(nums[idx+1:])
    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC108 - 将有序数组转换为二叉搜索树&lt;/h3&gt;
&lt;p&gt;既然已经是有序数组了，我们每次取中间部分即可。和前面的题没啥区别：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def sortedArrayToBST(self, nums: list[int]) -&amp;gt; TreeNode:
    if not nums:
        return None
    mid = len(nums)//2
    root = TreeNode(nums[mid])
    root.left = sortedArrayToBST(nums[:mid])
    root.right = sortedArrayToBST(nums[mid+1:])
    return root
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;序列化与反序列化&lt;/h2&gt;
&lt;h3&gt;LC297 - 二叉树的序列化与反序列化&lt;/h3&gt;
&lt;p&gt;这一题其实可以通过特判空的bfs来解决，这也是带null数组建树的过程和逆过程，我们来实操一下。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import deque

class Codec:
    def serialize(self, root):
        if not root:
            return &quot;&quot;

        q = deque([root])
        ans = []

        while q:
            node = q.popleft()

            if not node:
                ans.append(&quot;null&quot;)
                continue

            ans.append(str(node.val))
            q.append(node.left)
            q.append(node.right)

        return &quot;,&quot;.join(ans)

    def deserialize(self, data):
        if not data:
            return None

        vals = data.split(&quot;,&quot;)
        root = TreeNode(int(vals[0]))
        q = deque([root])
        # 反序列化需要用i指导位置，然后按照左右左右添加即可。
        i = 1

        while q:
            node = q.popleft()

            if vals[i] != &quot;null&quot;:
                node.left = TreeNode(int(vals[i]))
                q.append(node.left)
            i += 1

            if vals[i] != &quot;null&quot;:
                node.right = TreeNode(int(vals[i]))
                q.append(node.right)
            i += 1

        return root
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是既然放在这里，那就说明可以通过递归构造法来构造。实际上，我们只要先序遍历就能得到树的，虽然顺序不一样，但是序列化本来就是布置一种顺序，所以用dfs也是可以的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Codec:
    def serialize(self, root):
        vals = []

        def dfs(node):
            if not node:
                vals.append(&quot;null&quot;)
                return

            vals.append(str(node.val))
            dfs(node.left)
            dfs(node.right)

        dfs(root)
        return &quot;,&quot;.join(vals)

    def deserialize(self, data):
        vals = data.split(&quot;,&quot;)
        self.i = 0

        def dfs():
            if vals[self.i] == &quot;null&quot;:
                self.i += 1
                return None

            node = TreeNode(int(vals[self.i]))
            self.i += 1

            node.left = dfs()
            node.right = dfs()

            return node

        return dfs()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LC449 - 序列化和反序列化二叉搜索树&lt;/h3&gt;
&lt;p&gt;有左小右大规律，可以靠值范围恢复结构。这样，我们就不需要null了，直接按照前序建立。然后，反序列化的过程，用BST的上界和下界值域约束：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Codec:
    def serialize(self, root):
        vals = []

        def dfs(node):
            if not node:
                return
            vals.append(str(node.val))
            dfs(node.left)
            dfs(node.right)

        dfs(root)
        return &quot;,&quot;.join(vals)

    def deserialize(self, data):
        if not data:
            return None

        vals = list(map(int, data.split(&quot;,&quot;)))
        self.i = 0

        def dfs(lower, upper):
            if self.i == len(vals):
                return None

            val = vals[self.i]
            if val &amp;lt; lower or val &amp;gt; upper:
                return None

            self.i += 1
            node = TreeNode(val)
            node.left = dfs(lower, val)
            node.right = dfs(val, upper)
            return node

        return dfs(float(&quot;-inf&quot;), float(&quot;inf&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;数学递归：按公式定义问题&lt;/h1&gt;
&lt;h2&gt;阶乘&lt;/h2&gt;
&lt;p&gt;经典递归入门。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def solution(n:int):
    @cache
    def dfs(n:int):
        if n&amp;lt;=1:
            return 1
        return dfs(n-1)*n 
    return dfs(n)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;最大公约数&lt;/h2&gt;
&lt;p&gt;正常做法是从1找到一半（或者平方根）能整除n的数。最大公约数最经典的递归算法是 欧几里得算法 ，核心结论是 gcd(a, b) = gcd(b, a % b) 。所以我们辗转相除，最终一定能得到结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def gcd(a: int, b: int) -&amp;gt; int:
    a, b = abs(a), abs(b)
    if b == 0:
        return a
    return gcd(b, a % b)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;快速幂&lt;/h2&gt;
&lt;p&gt;本来x的n次幂要乘n次，时间复杂度是On。但是如果我们存中间的过程，每次把问题减半，就会降低复杂度到Onlogn&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def myPow(x: float, n: int) -&amp;gt; float:
    def dfs(n: int) -&amp;gt; float:
        if n == 0:
            return 1

        half = dfs(n // 2)

        if n % 2 == 0:
            return half * half
        else:
            return half * half * x

    if n &amp;gt;= 0:
        return dfs(n)
    else:
        return 1 / dfs(-n)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;面试题 08.06. 汉诺塔问题&lt;/h2&gt;
&lt;p&gt;在经典汉诺塔问题中，有 3 根柱子及 N 个不同大小的穿孔圆盘，盘子可以滑入任意一根柱子。一开始，所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制:
(1) 每次只能移动一个盘子;
(2) 盘子只能从柱子顶端滑出移到下一根柱子;
(3) 盘子只能叠在比它大的盘子上。&lt;/p&gt;
&lt;p&gt;请编写程序，用栈将所有盘子从第一根柱子移到最后一根柱子。&lt;/p&gt;
&lt;p&gt;汉诺塔问题也非常经典&lt;/p&gt;
&lt;p&gt;这题是递归最经典的数学模型之一。移动 &lt;code&gt;n&lt;/code&gt; 个盘子，看起来很复杂，但如果把最大的盘子单独拿出来看，事情就清楚了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 先把上面的 n-1 个盘子借助目标柱移到辅助柱
2. 再把最大的那个盘子移到目标柱
3. 最后把辅助柱上的 n-1 个盘子借助起始柱移到目标柱
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，汉诺塔的本质是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;移动 n 个盘子 = 先解决 n-1 个盘子 + 移动最大的盘子 + 再解决 n-1 个盘子
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归出口就是只剩一个盘子时，直接移动即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def hanoi(n: int, source: str, auxiliary: str, target: str) -&amp;gt; None:
    if n == 1:
        print(f&quot;{source} -&amp;gt; {target}&quot;)
        return

    hanoi(n - 1, source, target, auxiliary)
    print(f&quot;{source} -&amp;gt; {target}&quot;)
    hanoi(n - 1, auxiliary, source, target)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本题最少移动次数是 &lt;code&gt;2^n - 1&lt;/code&gt;，时间复杂度为 &lt;code&gt;O(2^n)&lt;/code&gt;，空间复杂度为递归栈深度 &lt;code&gt;O(n)&lt;/code&gt;。它特别适合帮助理解“把大问题拆成相同形式的小问题”这件事。&lt;/p&gt;
&lt;h2&gt;约瑟夫环&lt;/h2&gt;
&lt;h2&gt;卡特兰数&lt;/h2&gt;
&lt;h1&gt;递归优化&lt;/h1&gt;
&lt;h2&gt;记忆化搜索&lt;/h2&gt;
&lt;p&gt;记忆化搜索，就是在递归的基础上加一个缓存，把已经算过的状态保存起来，避免重复计算。&lt;/p&gt;
&lt;p&gt;它最适合这种场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;递归状态很多次重复出现。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;典型比如斐波那契数列，朴素递归会反复计算 &lt;code&gt;f(n-1)&lt;/code&gt;、&lt;code&gt;f(n-2)&lt;/code&gt;。加上记忆化之后，就能把指数级复杂度压下来。&lt;/p&gt;
&lt;p&gt;一个常见模板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

@cache
def dfs(state):
    if 递归出口:
        return base
    return 由更小状态转移得到的结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记忆化搜索本质上就是“自顶向下的动态规划”。&lt;/p&gt;
&lt;h2&gt;尾递归&lt;/h2&gt;
&lt;p&gt;尾递归指的是：递归调用是函数中的最后一步，当前层在递归返回后不再做额外运算。&lt;/p&gt;
&lt;p&gt;比如下面这种就更接近尾递归：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(n, acc):
    if n == 0:
        return acc
    return dfs(n - 1, acc + n)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过在 Python 里，尾递归并不会像某些语言那样自动优化掉栈空间，所以更多是一个概念上的了解，不用特别执着去写尾递归风格。&lt;/p&gt;
&lt;h2&gt;剪枝&lt;/h2&gt;
&lt;p&gt;剪枝就是：发现当前这条递归分支不可能成为答案时，提前停掉，不再继续往下搜。&lt;/p&gt;
&lt;p&gt;常见剪枝方式有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;超过目标值直接返回，比如组合总和&lt;/li&gt;
&lt;li&gt;同层重复元素直接跳过，比如子集II、组合总和II、全排列II&lt;/li&gt;
&lt;li&gt;当前状态已经不合法，立即返回，比如括号生成、N皇后&lt;/li&gt;
&lt;li&gt;当前最优值已经不可能超过已有答案，直接停掉&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;回溯题很多时候能不能通过，差别就在剪枝是否及时。&lt;/p&gt;
&lt;h2&gt;递归转迭代&lt;/h2&gt;
&lt;p&gt;递归本质上是系统帮我们维护调用栈，所以很多递归都能改写成“显式维护栈”的迭代。&lt;/p&gt;
&lt;p&gt;最常见的例子就是树遍历：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stack = [root]
while stack:
    node = stack.pop()
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一般来说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果题目天然是树、图、回溯结构，递归通常更直观&lt;/li&gt;
&lt;li&gt;如果数据规模很大，担心爆栈，或者逻辑本身就是线性的，迭代通常更稳&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;能不能从递归切到迭代，本质上就是看你能不能把“当前层还没做完的信息”自己存在栈里。&lt;/p&gt;
&lt;h2&gt;递归爆栈问题&lt;/h2&gt;
&lt;p&gt;当递归层数太深时，就会爆栈。最常见的情况有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表递归，但链表很长&lt;/li&gt;
&lt;li&gt;树退化成链表&lt;/li&gt;
&lt;li&gt;DFS 图/网格时搜索深度过大&lt;/li&gt;
&lt;li&gt;递归出口写错，导致无限递归&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以写递归题时，要特别注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;出口是否一定能到&lt;/li&gt;
&lt;li&gt;每次递归规模是否真的变小&lt;/li&gt;
&lt;li&gt;最坏情况下递归深度是多少&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Python递归深度限制&lt;/h2&gt;
&lt;p&gt;Python 默认递归深度不高，通常在千级左右。刷题时，如果递归层数可能达到几万，就要警惕。&lt;/p&gt;
&lt;p&gt;有时候可以临时调整：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import sys
sys.setrecursionlimit(10**6)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但这不是万能解法。真正更稳的方式还是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;改迭代&lt;/li&gt;
&lt;li&gt;优化递归结构&lt;/li&gt;
&lt;li&gt;减少不必要的深度&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;递归题目的分类判断&lt;/h1&gt;
&lt;h2&gt;看数据结构&lt;/h2&gt;
&lt;p&gt;如果题目本身给的是链表、树、图、网格，那么先想递归往往没错，因为这些结构天然具有“一个部分里还嵌着更小的同类部分”的特点。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;链表：当前节点 + 后续链表&lt;/li&gt;
&lt;li&gt;二叉树：当前节点 + 左右子树&lt;/li&gt;
&lt;li&gt;网格/图：当前点 + 四周可达状态&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;看是否可以拆成子问题&lt;/h2&gt;
&lt;p&gt;如果一个问题能自然地写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;当前问题 = 更小规模的同类问题 + 当前层处理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那它大概率就能用递归。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;反转链表：反转后续链表，再接回当前节点&lt;/li&gt;
&lt;li&gt;Pow(x, n)：先求 &lt;code&gt;x^(n//2)&lt;/code&gt;，再平方&lt;/li&gt;
&lt;li&gt;汉诺塔：先移动 &lt;code&gt;n-1&lt;/code&gt; 个盘子，再移动最大的&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;看是否需要枚举选择&lt;/h2&gt;
&lt;p&gt;如果每一步都有多个选择，而你需要把所有可能都试一遍，那通常就是回溯递归。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;子集：选或不选&lt;/li&gt;
&lt;li&gt;组合：下一个选哪个数&lt;/li&gt;
&lt;li&gt;排列：当前位置放谁&lt;/li&gt;
&lt;li&gt;N皇后：当前行放哪一列&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;看是否存在重复子问题&lt;/h2&gt;
&lt;p&gt;如果不同路径会反复进入同一个状态，那就不要只写朴素递归了，应该进一步考虑记忆化搜索。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;斐波那契&lt;/li&gt;
&lt;li&gt;爬楼梯&lt;/li&gt;
&lt;li&gt;编辑距离&lt;/li&gt;
&lt;li&gt;预测赢家&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些题如果只递归不缓存，复杂度通常会很难看。&lt;/p&gt;
&lt;h2&gt;看是否需要回溯撤销选择&lt;/h2&gt;
&lt;p&gt;如果你在递归过程中修改了某些状态，并且回到上一层后还要恢复原状，那这就是典型回溯。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;path.append(...)&lt;/code&gt; 之后要 &lt;code&gt;path.pop()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;棋盘上放皇后后，回来要撤销&lt;/li&gt;
&lt;li&gt;数独填数字后，回来要删掉&lt;/li&gt;
&lt;li&gt;网格搜索里标记访问后，回来要恢复&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;递归常见模板&lt;/h1&gt;
&lt;h2&gt;链表递归模板&lt;/h2&gt;
&lt;p&gt;链表递归常见于“返回处理后的链表头”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(head):
    if not head or not head.next:
        return head

    new_head = dfs(head.next)

    # 当前层处理指针关系
    ...

    return new_head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类题要特别注意：改指针之前，先想清楚当前层到底返回什么。&lt;/p&gt;
&lt;h2&gt;二叉树递归模板&lt;/h2&gt;
&lt;p&gt;二叉树递归最常见的是后序汇总：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(root):
    if not root:
        return base

    left = dfs(root.left)
    right = dfs(root.right)

    return 用 left 和 right 合并当前答案
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果题目需要自顶向下传信息，也可以写成前序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(root, state):
    if not root:
        return

    # 先处理当前节点
    dfs(root.left, new_state)
    dfs(root.right, new_state)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;分治递归模板&lt;/h2&gt;
&lt;p&gt;分治递归就是“先拆，再治，最后合并”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dfs(left, right):
    if left == right:
        return 单点答案

    mid = (left + right) // 2
    left_info = dfs(left, mid)
    right_info = dfs(mid + 1, right)

    return merge(left_info, right_info)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果题目涉及跨中点答案，就把“跨中点”也写在 &lt;code&gt;merge&lt;/code&gt; 里考虑进去。&lt;/p&gt;
&lt;h2&gt;回溯递归模板&lt;/h2&gt;
&lt;p&gt;组合、排列、切割、棋盘题大多长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ans = []
path = []

def dfs(start):
    if 达成答案:
        ans.append(path[:])
        return

    for i in range(start, 范围终点):
        if 当前选择不合法:
            continue

        path.append(选择)
        dfs(下一层状态)
        path.pop()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果是排列问题，通常没有 &lt;code&gt;start&lt;/code&gt;，而是用 &lt;code&gt;used&lt;/code&gt; 数组控制。&lt;/p&gt;
&lt;h2&gt;记忆化搜索模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from functools import cache

@cache
def dfs(state1, state2):
    if 递归出口:
        return base

    return 根据更小状态计算结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一旦题目有“区间 + 最优策略”“字符串前缀 + 最优值”“当前位置 + 剩余容量”这类重复状态，都可以先想这个模板。&lt;/p&gt;
&lt;h2&gt;DFS网格搜索模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def dfs(i, j):
    if 越界或当前位置不合法:
        return

    标记当前位置已访问

    dfs(i - 1, j)
    dfs(i + 1, j)
    dfs(i, j - 1)
    dfs(i, j + 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果题目要求统计面积、路径数、是否可达，就把 &lt;code&gt;return&lt;/code&gt; 的值改成对应的量即可。&lt;/p&gt;
&lt;h1&gt;递归问题总结&lt;/h1&gt;
&lt;p&gt;递归专题走到这里，最重要的其实不是记住多少题，而是形成几个稳定的直觉。&lt;/p&gt;
&lt;p&gt;第一，先定义函数，不要先写代码。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;这个递归函数到底返回什么？
它处理的是哪个子结构、哪个区间、哪个状态？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第二，出口一定要和函数定义匹配。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;空节点返回什么？
空区间返回什么？
目标达成时返回什么？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第三，很多递归题真正难的不是“怎么递”，而是“怎么归”。&lt;/p&gt;
&lt;p&gt;普通树题往往递下去不难，真正要想清楚的是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;回来的时候要汇总什么信息&lt;/li&gt;
&lt;li&gt;是否要维护全局答案&lt;/li&gt;
&lt;li&gt;是否需要在当前层做合并&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第四，回溯题的关键不是会不会写 &lt;code&gt;path.append()&lt;/code&gt; 和 &lt;code&gt;path.pop()&lt;/code&gt;，而是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;这一层的选择范围是什么？
什么情况该 continue？
什么情况该 break？
哪些状态要撤销？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第五，分治题不要先急着看代码，先问：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;左边返回什么？
右边返回什么？
当前层怎么合并？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后给自己留一个非常实用的判断口诀：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;树和链表，先想结构递归；
要试所有可能，先想回溯；
问题能切两半，先想分治；
状态重复出现，先想记忆化；
网格四向扩散，先想 DFS。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归不是一类题，而是一种看问题的方式。只要能把“当前问题”写成“更小的同类问题 + 当前层处理”，递归就自然出来了。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 核心组件 07：Agents</title><link>https://owen571.top/posts/study/langchain/09-agents/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/09-agents/</guid><description>把模型、消息、工具、记忆、流式与结构化输出重新装回一台真正能工作的机器里，再看 Agent 就会顺很多。</description><pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇我刻意放到最后。不是因为 Agents 不重要，而是因为它恰恰太重要、也太综合了。如果不先看底层组件，读 Agent 章节时很容易一直遇到“前面没学过但这里先用了”的跳跃感。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;Agent结合语言模型和工具，创建可以推理任务、决定使用哪些工具并逐步朝着解决方案工作的系统。&lt;a href=&quot;https://reference.langchain.com/python/langchain/agents/factory/create_agent&quot;&gt;create_agent&lt;/a&gt; 提供了一个生产就绪的Agent实现。LLM 代理在循环中运行工具以实现目标。代理运行直到满足停止条件，即当模型发出最终输出或达到迭代限制时。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;%%{
  init: {
    &quot;fontFamily&quot;: &quot;monospace&quot;,
    &quot;flowchart&quot;: {
      &quot;curve&quot;: &quot;curve&quot;
    }
  }
}%%
graph TD
  %% Outside the agent
  QUERY([input])
  LLM{model}
  TOOL(tools)
  ANSWER([output])

  %% Main flows (no inline labels)
  QUERY --&amp;gt; LLM
  LLM --&quot;action&quot;--&amp;gt; TOOL
  TOOL --&quot;observation&quot;--&amp;gt; LLM
  LLM --&quot;finish&quot;--&amp;gt; ANSWER

  classDef blueHighlight fill:#DBEAFE,stroke:#2563EB,color:#1E3A8A;
  classDef greenHighlight fill:#DCFCE7,stroke:#16A34A,color:#14532D;
  class QUERY blueHighlight;
  class ANSWER blueHighlight;
  class LLM greenHighlight;
  class TOOL greenHighlight;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如上图，换句话说，create_agent 使用 LangGraph 构建基于图的Agent运行时。一个图由节点（步骤）和边（连接）组成，定义了Agent如何处理信息。代理通过这个图移动，执行节点，例如模型节点（调用模型）、工具节点（执行工具）或中间件。&lt;/p&gt;
&lt;h2&gt;2. 模型 (Model)&lt;/h2&gt;
&lt;p&gt;模型是Agent的大脑、推理引擎。有多种方式可以指定。&lt;/p&gt;
&lt;h3&gt;(1) 静态模型&lt;/h3&gt;
&lt;p&gt;我们通过传入能被识别的模型字符串，可以直接定义静态模型。字符串映射的完整列表可以看&lt;a href=&quot;https://reference.langchain.com/python/langchain/chat_models/base/init_chat_model&quot;&gt;这里&lt;/a&gt;，里面有详细的关于model、provider等等的参数映射，用不同模型的时候可以找这里。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent

agent = create_agent(&quot;openai:gpt-5&quot;, tools=tools)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果要更好控制模型，就需要直接用provider的包，比如之前用过的ChatOpenAI，按照如下的方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain_openai import ChatOpenAI

model = ChatOpenAI(
    model=&quot;gpt-5&quot;,
    temperature=0.1,
    max_tokens=1000,
    timeout=30
    # ... (other params)
)
agent = create_agent(model, tools=tools)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的参数完全由你控制，不同Provider提供的用法查询可以看&lt;a href=&quot;https://docs.langchain.com/oss/python/integrations/providers/all_providers&quot;&gt;这里&lt;/a&gt;。至于具体参数都怎么使用，可以看&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/models#parameters&quot;&gt;这里&lt;/a&gt;。&lt;/p&gt;
&lt;h3&gt;(2) 动态模型&lt;/h3&gt;
&lt;p&gt;使用&lt;code&gt;@wrap_model_call&lt;/code&gt;创建中间件，就可以在运行时根据当前状态或上下文进行选择。官网的例子如下，实现了一个简单的根据信息长度筛选模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain_openai import ChatOpenAI
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse


basic_model = ChatOpenAI(model=&quot;gpt-4.1-mini&quot;)
advanced_model = ChatOpenAI(model=&quot;gpt-4.1&quot;)

@wrap_model_call
def dynamic_model_selection(request: ModelRequest, handler) -&amp;gt; ModelResponse:
    &quot;&quot;&quot;Choose model based on conversation complexity.&quot;&quot;&quot;
    message_count = len(request.state[&quot;messages&quot;])

    if message_count &amp;gt; 10:
        # Use an advanced model for longer conversations
        model = advanced_model
    else:
        model = basic_model

    return handler(request.override(model=model))

agent = create_agent(
    model=basic_model,  # Default model
    tools=tools,
    middleware=[dynamic_model_selection]
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用结构化输出的时候，不支持预绑定模型。&lt;/p&gt;
&lt;p&gt;我们来细看一下代码，这个中间件函数会在模型被真正调用之前，先拿到请求看看，需不需要进行某些程度上的更改，改完之后再交给下一个处理者继续进行。要用的东西，我们都是用&lt;code&gt;from langchain.agents.middleware import wrap_model_call&lt;/code&gt;来导入。比如&lt;code&gt;ModelRequest&lt;/code&gt;类，是一个模型请求类，我们用&lt;code&gt;request&lt;/code&gt;这个实例来拿到模型请求对象，这里包含了很多与模型调用有关的信息，比如:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;request.state：当前 agent 的状态&lt;/li&gt;
&lt;li&gt;request.tools：当前可用工具列表&lt;/li&gt;
&lt;li&gt;request.model：当前要用的模型&lt;/li&gt;
&lt;li&gt;request.messages：当前消息历史&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;handler则只是一个“可调用对象”，可以先当做这样一个普通函数理解:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def handler(request: ModelRequest) -&amp;gt; ModelResponse:
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上述例子，我们修改完model之后，用&lt;code&gt;override&lt;/code&gt;重写model，返回&lt;code&gt;handler&lt;/code&gt;之后的结果。所以过程其实是这样的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先看当前请求&lt;/li&gt;
&lt;li&gt;按条件修改 request，比如切换 model&lt;/li&gt;
&lt;li&gt;再调用 handler，把修改后的 request 继续传下去&lt;/li&gt;
&lt;li&gt;拿到最终响应并返回&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，我们在agent创建的时候，将中间件函数写进中间件中即可，如&lt;code&gt;middleware=[dynamic_model_selection]&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;3. 工具 (Tool)&lt;/h2&gt;
&lt;p&gt;工具赋予Agent“take action”的能力。Agent超越了简单的仅模型工具绑定，通过促进：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多次工具调用的顺序（由单个提示触发）&lt;/li&gt;
&lt;li&gt;在适当的情况下并行工具调用&lt;/li&gt;
&lt;li&gt;根据之前的结果动态选择工具&lt;/li&gt;
&lt;li&gt;工具重试逻辑和错误处理&lt;/li&gt;
&lt;li&gt;工具调用之间的状态持久性等能力&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可以在这里看到&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/tools&quot;&gt;工具&lt;/a&gt;的详细信息，不过之后也会学习的。&lt;/p&gt;
&lt;h3&gt;(1) 静态工具&lt;/h3&gt;
&lt;p&gt;在创建agent时构建，整个执行过程中保持不变的工具，这是最常见和最直接的方法。官网示例如下（没错还是假天气工具）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool
from langchain.agents import create_agent


@tool
def search(query: str) -&amp;gt; str:
    &quot;&quot;&quot;Search for information.&quot;&quot;&quot;
    return f&quot;Results for: {query}&quot;

@tool
def get_weather(location: str) -&amp;gt; str:
    &quot;&quot;&quot;Get weather information for a location.&quot;&quot;&quot;
    return f&quot;Weather in {location}: Sunny, 72°F&quot;

agent = create_agent(model, tools=[search, get_weather])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果提供的是空工具列表的话，代理就会由一个没有调用工具能力的单一LLM节点组成。&lt;/p&gt;
&lt;h3&gt;(2) 动态工具&lt;/h3&gt;
&lt;p&gt;工具过多会使模型过载上下文并添加错误的可能，而过少又会限制能力，因此我们需要动态工具。动态工具选择使可用工具集能够根据身份验证状态、用户权限、功能标志或对话阶段进行调整。依然使用&lt;code&gt;@wrap_model_call&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;根据工具是否已知，可以采用两种方法。&lt;/p&gt;
&lt;p&gt;第一种方法，Filtering pre-registered tools。我们通过将预先的规则写进中间件函数里，达到动态调整工具的效果。又可细分为三个方面，一个是从request的state（&lt;code&gt;request.state&lt;/code&gt;）来限制、一个是按照存储内容中的用户偏好或者功能标记来筛选（&lt;code&gt;request.runtime.store&lt;/code&gt;）、还有通过运行时候的上下文（&lt;code&gt;requset.runtime.context...&lt;/code&gt;）进行筛选。这里不展开，后面学到runtime会细聊。&lt;/p&gt;
&lt;p&gt;比如我们可以举一个state的例子，具体依靠两个条件：用户是否认证&lt;code&gt;is_authenticated&lt;/code&gt;和对话消息数是否达标&lt;code&gt;message_count&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from typing import Callable

@wrap_model_call
def state_based_tools(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse]
) -&amp;gt; ModelResponse:
    &quot;&quot;&quot;Filter tools based on conversation State.&quot;&quot;&quot;
    # Read from State: check if user has authenticated
    state = request.state
    is_authenticated = state.get(&quot;authenticated&quot;, False)
    message_count = len(state[&quot;messages&quot;])

    # Only enable sensitive tools after authentication
    if not is_authenticated:
        tools = [t for t in request.tools if t.name.startswith(&quot;public_&quot;)]
        request = request.override(tools=tools)
    elif message_count &amp;lt; 5:
        # Limit tools early in conversation
        tools = [t for t in request.tools if t.name != &quot;advanced_search&quot;]
        request = request.override(tools=tools)

    return handler(request)

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[public_search, private_search, advanced_search],
    middleware=[state_based_tools]
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里中间件写的看上去比动态agent更唬人，实际上只是类型标注更详细了，不过也好，能更深刻理解。这里多引入了&lt;code&gt;typing&lt;/code&gt;包里面的&lt;code&gt;Callable&lt;/code&gt;，如果你python和我一样稀烂可能还不了解，我临时学了下。可以使用&lt;code&gt;Callable&lt;/code&gt;来指定参数和返回值的类型。&lt;code&gt;Callable[[Arg1Type, Arg2Type, ...], ReturnType]&lt;/code&gt;表示一个接受特定参数类型并返回特定类型值的可调用对象。比如这里，&lt;code&gt;Callable[[ModelRequest], ModelResponse]&lt;/code&gt;，表示接受&lt;code&gt;ModelRequest&lt;/code&gt;并返回&lt;code&gt;ModelResponse&lt;/code&gt;的可调用对象（也就是可以像函数一样直接加个&lt;code&gt;()&lt;/code&gt;来执行的对象，包括普通函数、类（调用类就是创建实例）、实现了&lt;code&gt;__call__()&lt;/code&gt;的对象）。弄个简单的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Callable
 
# 定义一个接受两个整数并返回一个整数的可调用对象
def add(a: int, b: int) -&amp;gt; int:
    return a + b
 
def my_function(callback: Callable[[int, int], int]) -&amp;gt; None:
    result = callback(1, 2)
    print(result)
 
my_function(add)  # 输出: 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;意思是，这里是一个可调用对象&lt;code&gt;Callable[[int, int], int]&lt;/code&gt;，也就是一个接受两个整数、返回一个整数的可调用对象。然后我们普通函数&lt;code&gt;def add(a: int, b: int) -&amp;gt; int&lt;/code&gt;显然满足这个条件，我们可以传入。&lt;/p&gt;
&lt;p&gt;回到这里，写&lt;code&gt;Callable[[ModelRequest], ModelResponse]&lt;/code&gt;，实际上就是为了给&lt;code&gt;handler&lt;/code&gt;写类型标注，表示它是一个接收&lt;code&gt;ModelRequest&lt;/code&gt;、输出&lt;code&gt;ModelResponse&lt;/code&gt;的可调用对象。这也是中间件里“把请求继续交给下一个处理者”的关键。也就是说，这里和动态model是一样的，只不过写得更加详细一点。&lt;/p&gt;
&lt;p&gt;我们继续，刚才说了第一种情况是Filtering pre-registered tools，现在我们来介绍第二种情况，即Runtime tool registration。&lt;/p&gt;
&lt;p&gt;当工具在运行时被发现或者创建时（比如MCP服务器加载、基于用户数据生产、或者远程注册表获得），需要既注册这些工具，又动态处理它们的执行。所以这里有两个中间件钩子来使用，一个是&lt;code&gt;wrap_model_call&lt;/code&gt;，老朋友，可以用这个在模型推理前将工具动态添加到requset；另外还有一个&lt;code&gt;wrap_tool_call&lt;/code&gt;，负责在真正执行工具时把工具名映射到实际的工具函数。&lt;/p&gt;
&lt;p&gt;示例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.tools import tool
from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ToolCallRequest

# A tool that will be added dynamically at runtime
@tool
def calculate_tip(bill_amount: float, tip_percentage: float = 20.0) -&amp;gt; str:
    &quot;&quot;&quot;Calculate the tip amount for a bill.&quot;&quot;&quot;
    tip = bill_amount * (tip_percentage / 100)
    return f&quot;Tip: ${tip:.2f}, Total: ${bill_amount + tip:.2f}&quot;

class DynamicToolMiddleware(AgentMiddleware):
    &quot;&quot;&quot;Middleware that registers and handles dynamic tools.&quot;&quot;&quot;

    def wrap_model_call(self, request: ModelRequest, handler):
        # Add dynamic tool to the request
        # This could be loaded from an MCP server, database, etc.
        updated = request.override(tools=[*request.tools, calculate_tip])
        return handler(updated)

    def wrap_tool_call(self, request: ToolCallRequest, handler):
        # Handle execution of the dynamic tool
        if request.tool_call[&quot;name&quot;] == &quot;calculate_tip&quot;:
            return handler(request.override(tool=calculate_tip))
        return handler(request)

agent = create_agent(
    model=&quot;gpt-4o&quot;,
    tools=[get_weather],  # Only static tools registered here
    middleware=[DynamicToolMiddleware()],
)

# The agent can now use both get_weather AND calculate_tip
result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Calculate a 20% tip on $85&quot;}]
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读一下，啥意思呢，这段函数定义了一个找小费的函数，但是定义agent的时候没有传进去。但是，我们从中间件包里导入了&lt;code&gt;AgentMiddleware&lt;/code&gt;，是插在agetn运行流程中的中间层基类，从而给Agent扩展了动态添加tool的能力。&lt;/p&gt;
&lt;p&gt;我们继承这个基类后，自定义一个agent中间件类，常见用法就是重写这些方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyMiddleware(AgentMiddleware):
    def before_model(self, state, runtime):
        ...

    def after_model(self, state, runtime):
        ...

    def wrap_model_call(self, request, handler):
        ...

    def wrap_tool_call(self, request, handler):
        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如何理解这几个函数，实际上，我们可以把agent运行流程分为如下的过程，并且只有4个插手的时机：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;before_model，调用模型前执行，拿到的是agent的状态state，适合检查消息、权限、标志位、更新状态、提起终止流程、决定是否跳转。&lt;/li&gt;
&lt;li&gt;wrap_model_call，把整个模型调用包起来，不仅能在前后做事，还能直接修改模型请求、决定是不是需要继续调用、改模型、改工具、改system prompt，功能非常强大，最后再调用handler(request)把流程传下去即可。&lt;/li&gt;
&lt;li&gt;模型真正执行&lt;/li&gt;
&lt;li&gt;after_model，模型返回结果后执行，适合做记录日志、统计调用次数、读取生成内容、根据结果更新状态。&lt;/li&gt;
&lt;li&gt;如果模型决定调工具：&lt;/li&gt;
&lt;li&gt;wrap_tool_call，把整个工具调用包起来，在工具真正执行前触发，可以用于拦截某些工具调用、修改工具调用请求、动态替换工具、给工具添加日志、权限校验、错误处理。&lt;/li&gt;
&lt;li&gt;工具真正执行&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后，我们在创建agent的时候将这个类的实例创建传给create_agent即可，类似&lt;code&gt;middleware=[MyMiddleware()]&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;回到上例，我们实现了一个动态工具中间基类，在wrap_model_call环节重写tools，加入了新工具。然后wrap_tool_call动态处理调用名为&quot;calculate_tip&quot;时，将这次请求的工具调用改为calculate_tip。&lt;/p&gt;
&lt;p&gt;为什么需要这两个过程？因为前者只是让模型选工具阶段知道有这么一个工具名，让它有机会吐出&lt;code&gt;{&quot;name&quot;: &quot;calculate_tip&quot;, ...}&lt;/code&gt;这样的输出，而调用的时候需要一个实际可执行的工具，所以需要动态注册 —— 这次执行的Python工具就是calculate_tip对象，即&lt;code&gt;return handler(request.override(tool=calculate_tip))&lt;/code&gt;，实现双hook动态注册。&lt;/p&gt;
&lt;p&gt;顺带一提，钩子 = 框架提前留好的“插入点”或“扩展点”让你能在某个时机把自己的逻辑“挂进去”，是一种广泛约定的设计思想。&lt;/p&gt;
&lt;h3&gt;(3) 工具调用错误处理&lt;/h3&gt;
&lt;p&gt;中间件确实牛逼，我们还可以用&lt;code&gt;@wrap_tool_call&lt;/code&gt;这个钩子来自定义工具错误的处理方式。&lt;/p&gt;
&lt;p&gt;比如下面的例子中，我们给钩子中写了一个try-catch，然后发成错误的时候，返回一个错误信息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain.messages import ToolMessage


@wrap_tool_call
def handle_tool_errors(request, handler):
    &quot;&quot;&quot;Handle tool execution errors with custom messages.&quot;&quot;&quot;
    try:
        return handler(request)
    except Exception as e:
        # Return a custom error message to the model
        return ToolMessage(
            content=f&quot;Tool error: Please check your input and try again. ({str(e)})&quot;,
            tool_call_id=request.tool_call[&quot;id&quot;]
        )

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[search, get_weather],
    middleware=[handle_tool_errors]
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样的话，agent会返回一个&lt;a href=&quot;https://reference.langchain.com/python/langchain-core/messages/tool/ToolMessage&quot;&gt;ToolMessage&lt;/a&gt;，带有你刚才自己写的错误信息内容。&lt;/p&gt;
&lt;h3&gt;(4) 在ReAct循环中使用工具&lt;/h3&gt;
&lt;p&gt;我们可以会看最开始介绍Agent的图片，实际上Agent是遵循一种ReAct (“Reasoning + Acting”)模式来工作的，具体而言就是在简要推理步骤与针对性工具调用之间交替，并将产生的观察结果反馈到后续决策中，直到能够给出最终答案。&lt;/p&gt;
&lt;p&gt;模型什么时候回停止Loop，在create_agent中描述的很明确：模型产出最终回答或者达到迭代上限。一般是交给 agent 里的模型自己判断“现在能不能回答了”，去完成这样一个过程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型先看当前消息和工具结果&lt;/li&gt;
&lt;li&gt;如果它觉得还需要外部信息，就发起tool_calls&lt;/li&gt;
&lt;li&gt;工具执行完，结果回到上下文&lt;/li&gt;
&lt;li&gt;模型再判断一次&lt;/li&gt;
&lt;li&gt;如果它已经有足够信息，就直接给出最终答案，不再调用工具，这时循环结束&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. System Prompt&lt;/h2&gt;
&lt;h3&gt;(1) system_prompt&lt;/h3&gt;
&lt;p&gt;可以通过系统提示词，来定义agent如何处理任务。如果没有提供，agent将从消息中推断任务。&lt;/p&gt;
&lt;p&gt;system_prompt接受str或者SystemMessage作为输入，使用SystemMessage能让你对提示词结构拥有更多控制权。比如下面的例子中，我们把整本书放进 system prompt，让 agent 以这本书为固定上下文来回答问题”的例子，而且它顺手演示了 Anthropic 的 prompt（Anthropic的提示缓存能力&lt;code&gt;cache_control: {&quot;type&quot;: &quot;ephemeral&quot;}&lt;/code&gt;，代表这样标记后，会缓存这个内容块，在重复请求时降低延迟和成本。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.messages import SystemMessage, HumanMessage

literary_agent = create_agent(
    model=&quot;anthropic:claude-sonnet-4-5&quot;,
    system_prompt=SystemMessage(
        content=[
            {
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: &quot;You are an AI assistant tasked with analyzing literary works.&quot;,
            },
            {
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: &quot;&amp;lt;the entire contents of &apos;Pride and Prejudice&apos;&amp;gt;&quot;,
                &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
            }
        ]
    )
)

result = literary_agent.invoke(
    {&quot;messages&quot;: [HumanMessage(&quot;Analyze the major themes in &apos;Pride and Prejudice&apos;.&quot;)]}
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 动态系统提示词&lt;/h3&gt;
&lt;p&gt;动态的实现方式跟前面其实也没什么区别，这次是使用&lt;code&gt;@dynamic_prompt&lt;/code&gt;中间件，来更新system_prompt，用法举例如下，这是根据运行时上下文来决定system_message：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import TypedDict

from langchain.agents import create_agent
from langchain.agents.middleware import dynamic_prompt, ModelRequest


class Context(TypedDict):
    user_role: str

@dynamic_prompt
def user_role_prompt(request: ModelRequest) -&amp;gt; str:
    &quot;&quot;&quot;Generate system prompt based on user role.&quot;&quot;&quot;
    user_role = request.runtime.context.get(&quot;user_role&quot;, &quot;user&quot;)
    base_prompt = &quot;You are a helpful assistant.&quot;

    if user_role == &quot;expert&quot;:
        return f&quot;{base_prompt} Provide detailed technical responses.&quot;
    elif user_role == &quot;beginner&quot;:
        return f&quot;{base_prompt} Explain concepts simply and avoid jargon.&quot;

    return base_prompt

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[web_search],
    middleware=[user_role_prompt],
    context_schema=Context
)

# The system prompt will be set dynamically based on context
result = agent.invoke(
    {&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Explain machine learning&quot;}]},
    context={&quot;user_role&quot;: &quot;expert&quot;}
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里需要说明一下，你也可以直接在&lt;code&gt;@wrap_model_call&lt;/code&gt;装饰器下修改，它更加通用。不过建议用&lt;code&gt;@dynamic_prompt&lt;/code&gt;，语义清晰、代码直观，所以建议用这个。&lt;/p&gt;
&lt;h2&gt;5. Name&lt;/h2&gt;
&lt;p&gt;这是一个可选项，给agent起名，在多智能体系统中将该智能体作为子图添加时，此名称会用做节点标识符。（建议用snake_case命名，防止某些模型提供商不支持含空格或特殊字符的名称）。&lt;/p&gt;
&lt;h2&gt;6. Advanced concepts&lt;/h2&gt;
&lt;h3&gt;(1) Structured output&lt;/h3&gt;
&lt;p&gt;在某些情况下，你可能希望智能体以特定格式返回输出。LangChain 通过&lt;a href=&quot;https://reference.langchain.com/python/langchain/agents/factory/create_agent&quot;&gt;response_format&lt;/a&gt;参数提供了结构化输出的策略。&lt;/p&gt;
&lt;h3&gt;(2) ToolStrategy&lt;/h3&gt;
&lt;p&gt;ToolStrategy利用人工工具调用生成结构化输出。这适用于任何支持工具调用的模型。当原生提供商的结构化输出（通过ProviderStrategy）不可用或不可靠时，应使用ToolStrategy。用法也很简单，直接调用的时候传入一个Pydantic的模型给&lt;code&gt;response_format&lt;/code&gt;参数就行。&lt;/p&gt;
&lt;h3&gt;(3) ProvideStrategy&lt;/h3&gt;
&lt;p&gt;ProviderStrategy使用模型提供商的原生结构化输出生成功能。这种方式更可靠，但仅适用于支持原生结构化输出的提供商&lt;/p&gt;
&lt;h2&gt;7. 记忆 (Memory)&lt;/h2&gt;
&lt;p&gt;智能体通过消息状态自动保存对话历史。你也可以配置智能体使用自定义状态模式，以在对话过程中记住额外信息。状态中存储的信息可以被视为智能体的短期记忆：&lt;/p&gt;
&lt;p&gt;自定义状态模式必须作为TypedDict扩展AgentState。&lt;/p&gt;
&lt;p&gt;有两种定义自定义状态的方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;通过中间件（推荐）&lt;/li&gt;
&lt;li&gt;通过create_agent上的state_schema&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) 通过中间件自定义状态&lt;/h3&gt;
&lt;p&gt;当你的自定义状态需要被特定的中间件钩子以及附加到该中间件上的工具访问时，请使用中间件来定义自定义状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from typing import Any


class CustomState(AgentState):
    user_preferences: dict

class CustomMiddleware(AgentMiddleware):
    state_schema = CustomState
    tools = [tool1, tool2]

    def before_model(self, state: CustomState, runtime) -&amp;gt; dict[str, Any] | None:
        ...

agent = create_agent(
    model,
    tools=tools,
    middleware=[CustomMiddleware()]
)

# The agent can now track additional state beyond messages
result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;I prefer technical explanations&quot;}],
    &quot;user_preferences&quot;: {&quot;style&quot;: &quot;technical&quot;, &quot;verbosity&quot;: &quot;detailed&quot;},
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上述例子就是在给agent拓展默认消息状态之外的自定义状态，默认状态下，agent主要会维护messages这样的基础状态，而这里有定义了一个&lt;code&gt;CustomState&lt;/code&gt;继承AgentState类，这样以后就会多携带一个user_preferences的字典。&lt;/p&gt;
&lt;p&gt;紧接着，我们再次用到&lt;code&gt;AgentMiddleware&lt;/code&gt;中间件，将里面的&lt;code&gt;state_schema = CustomState&lt;/code&gt;，就告诉了agent，希望整个agent使用CustomState这种状态结构。紧接着，在之前提到过的&lt;code&gt;before_model&lt;/code&gt;函数，更新agent的state即可（方法是直接传入），这里还可以多实现一些基于user_preferences的逻辑。&lt;/p&gt;
&lt;p&gt;然后我们可以看到，在调用agent的时候，除了message还传入了user_preferences。&lt;/p&gt;
&lt;h3&gt;(2) 通过&lt;code&gt;state_schema&lt;/code&gt;自定义状态&lt;/h3&gt;
&lt;p&gt;使用state_schema参数作为快捷方式，定义仅在工具中使用的自定义状态。例子如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import AgentState


class CustomState(AgentState):
    user_preferences: dict

agent = create_agent(
    model,
    tools=[tool1, tool2],
    state_schema=CustomState
)
# The agent can now track additional state beyond messages
result = agent.invoke({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;I prefer technical explanations&quot;}],
    &quot;user_preferences&quot;: {&quot;style&quot;: &quot;technical&quot;, &quot;verbosity&quot;: &quot;detailed&quot;},
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方法和中间件操作法有什么区别呢？官方更推荐作用域更清晰、更模块化的中间件方式，而这个方法则是直接给整个Agent制定一份自定义结构状态，没办法细分跟哪组工具、哪个中间件有关，不够聚焦。&lt;/p&gt;
&lt;h2&gt;8. Streaming&lt;/h2&gt;
&lt;p&gt;我们已经了解到可以通过invoke方法调用智能体以获取最终响应。如果智能体需要执行多个步骤，这一过程可能会耗费一定时间。为了展示中间执行进度，我们可以在消息产生时将其流式返回。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.messages import AIMessage, HumanMessage

for chunk in agent.stream({
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Search for AI news and summarize the findings&quot;}]
}, stream_mode=&quot;values&quot;):
    # Each chunk contains the full state at that point
    latest_message = chunk[&quot;messages&quot;][-1]
    if latest_message.content:
        if isinstance(latest_message, HumanMessage):
            print(f&quot;User: {latest_message.content}&quot;)
        elif isinstance(latest_message, AIMessage):
            print(f&quot;Agent: {latest_message.content}&quot;)
    elif latest_message.tool_calls:
        print(f&quot;Calling tools: {[tc[&apos;name&apos;] for tc in latest_message.tool_calls]}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里启动一次agent执行，执行过程中会不断出现chunk，然后用stream_mode = &quot;values&quot;表示每个chunk都是当前时刻的完整的state，所以每次都拿到完整的状态。然后你就懂了，chunk[&quot;messages&quot;][-1]就是最后一条信息，用两步判断，如果有内容，再判断是人还是AI说的，输出信息和文本。如果没有内容但是有工具调用，就输出工具调用信息。&lt;/p&gt;
&lt;h2&gt;9. 中间件 (Middleware)&lt;/h2&gt;
&lt;p&gt;前面已经用过了很多了，这里再做一个简单的总结：&lt;/p&gt;
&lt;p&gt;中间件具备强大的可扩展性，可在执行的不同阶段自定义智能体行为。你可以通过中间件实现以下功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在调用模型前处理状态（例如消息裁剪、上下文注入）&lt;/li&gt;
&lt;li&gt;修改或验证模型的响应（例如安全防护、内容过滤）&lt;/li&gt;
&lt;li&gt;通过自定义逻辑处理工具执行错误&lt;/li&gt;
&lt;li&gt;基于状态或上下文实现动态模型选择&lt;/li&gt;
&lt;li&gt;添加自定义日志记录、监控或分析功能&lt;/li&gt;
&lt;li&gt;中间件可无缝融入智能体的执行流程，让你能够在关键节点拦截并修改数据流，而无需更改智能体的核心逻辑。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;上面如果都能想到是怎么做的，那就差不多了解清楚这个Agents特性了。&lt;/p&gt;
</content:encoded></item><item><title>RAG 查询构建：从元数据过滤到 Text2SQL</title><link>https://owen571.top/posts/study/rag/10-rag-%E6%9F%A5%E8%AF%A2%E6%9E%84%E5%BB%BA-%E4%BB%8E%E5%85%83%E6%95%B0%E6%8D%AE%E8%BF%87%E6%BB%A4%E5%88%B0-text2sql/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/10-rag-%E6%9F%A5%E8%AF%A2%E6%9E%84%E5%BB%BA-%E4%BB%8E%E5%85%83%E6%95%B0%E6%8D%AE%E8%BF%87%E6%BB%A4%E5%88%B0-text2sql/</guid><description>当知识源不再只是纯文本时，RAG 不能只做语义匹配，还要学会把自然语言问题翻成过滤器、Cypher 或 SQL。</description><pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;到这里，检索已经不只是“把一句问题编码成向量再去搜文本”。如果底层数据源本身带结构，查询构建的关键就在于先把自然语言翻译成合适的查询表达式。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 查询创建&lt;/h1&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;前面大多学习从非结构化的数据中检索信息，但是实际应用中，我们常常需要处理更加复杂和多样化的数据，包括结构化数据（如SQL数据库）、半结构化数据（如带有元数据的文档）以及图数据。用户的查询也可能不仅仅是简单的语义匹配，而是包含复杂的过滤条件、聚合操作或关系查询。&lt;/p&gt;
&lt;p&gt;查询构建（Query Construction）正是应对这一挑战的关键技术。它利用大语言模型（LLM）的强大理解能力，将用户的自然语言查询“翻译”成针对特定数据源的结构化查询语言或带有过滤条件的请求。这使得RAG系统能够无缝地连接和利用各种类型的数据，从而极大地扩展了其应用场景和能力。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-35.DOGNyXuF.png&amp;amp;w=1521&amp;amp;h=878&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. 从文本到元数据过滤器&lt;/h2&gt;
&lt;p&gt;在构建向量索引时，常常会为文档块（Chunks）附加元数据（Metadata），例如文档来源、发布日期、作者、章节、类别等。这些元数据为我们提供了在语义搜索之外进行精确过滤的可能。&lt;/p&gt;
&lt;p&gt;自查询检索器（Self-Query Retriever） 是LangChain中实现这一功能的核心组件。它的工作流程如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;定义元数据结构：首先，需要向LLM清晰地描述文档内容和每个元数据字段的含义及类型。&lt;/li&gt;
&lt;li&gt;查询解析：当用户输入一个自然语言查询时，自查询检索器会调用LLM，将查询分解为两部分：
&lt;ul&gt;
&lt;li&gt;查询字符串（Query String）：用于进行语义搜索的部分。&lt;/li&gt;
&lt;li&gt;元数据过滤器（Metadata Filter）：从查询中提取出的结构化过滤条件。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;执行查询：检索器将解析出的查询字符串和元数据过滤器发送给向量数据库，执行一次同时包含语义搜索和元数据过滤的查询。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;例如，对于查询“关于2022年发布的机器学习的论文”，自查询检索器会将其解析为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;查询字符串: &quot;机器学习的论文&quot;&lt;/li&gt;
&lt;li&gt;元数据过滤器: year == 2022&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面，来看看SelfQueryRetriever的最小示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os

from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_milvus import Milvus

from langchain_classic.chains.query_constructor.schema import AttributeInfo
from langchain_classic.retrievers.self_query.base import SelfQueryRetriever
from langchain_community.query_constructors.milvus import MilvusTranslator


# 1. 模型
llm = ChatOpenAI(
    model=&quot;gpt-4o-mini&quot;,
    temperature=0,
    api_key=os.environ[&quot;QIHANG_API&quot;],
    base_url=os.environ[&quot;QIHANG_BASE_URL&quot;],
)

embeddings = OpenAIEmbeddings(
    model=&quot;text-embedding-3-small&quot;,
    api_key=os.environ[&quot;QIHANG_API&quot;],
    base_url=os.environ[&quot;QIHANG_BASE_URL&quot;],
)

# 2. 连接你现有的 Milvus collection
vectorstore = Milvus(
    embedding_function=embeddings,
    collection_name=&quot;RL_docs&quot;,
    connection_args={&quot;uri&quot;: os.environ[&quot;MILVUS_URL&quot;]},
    primary_field=&quot;id&quot;,
    text_field=&quot;text&quot;,
    vector_field=&quot;vector&quot;,
    search_params={&quot;metric_type&quot;: &quot;COSINE&quot;, &quot;params&quot;: {}},
)

# 3. 告诉 SelfQueryRetriever：你的 metadata 有哪些字段
metadata_field_info = [
    AttributeInfo(
        name=&quot;h1&quot;,
        description=&quot;一级标题，例如 一. 鸿沟 -- 为什么需要RL ?&quot;,
        type=&quot;string&quot;,
    ),
    AttributeInfo(
        name=&quot;h2&quot;,
        description=&quot;二级标题，例如 4. 强化学习的特征与历史&quot;,
        type=&quot;string&quot;,
    ),
    AttributeInfo(
        name=&quot;h3&quot;,
        description=&quot;三级标题，例如 3. Q-learning&quot;,
        type=&quot;string&quot;,
    ),
]

document_content_description = &quot;强化学习学习笔记的文本片段&quot;

# 4. 创建 SelfQueryRetriever
retriever = SelfQueryRetriever.from_llm(
    llm=llm,
    vectorstore=vectorstore,
    document_contents=document_content_description,
    metadata_field_info=metadata_field_info,
    structured_query_translator=MilvusTranslator(),
    enable_limit=True,
    search_kwargs={&quot;k&quot;: 4},
    verbose=True,
)

# 5. 直接自然语言检索
docs = retriever.invoke(&quot;只看三级标题和 Q-learning 有关的内容，返回2条&quot;)

for i, doc in enumerate(docs, start=1):
    print(f&quot;--- 文档 {i} ---&quot;)
    print(doc.metadata)
    print(doc.page_content[:300])
    print()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;提一嘴就是现在结构化主要通过Schema等验证去做了，你可以看到这个方法已经被放到&lt;code&gt;langchain_classic.retrievers.self_query.base &lt;/code&gt;里面了。&lt;/p&gt;
&lt;h2&gt;3. 从文本到Cypher&lt;/h2&gt;
&lt;p&gt;与“文本到元数据过滤器”类似，“文本到Cypher”技术利用大语言模型（LLM）将用户的自然语言问题直接翻译成一句精准的 Cypher 查询语句。LangChain 提供了相应的工具链（如 GraphCypherQAChain），其工作流程通常是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;接收用户的自然语言问题。&lt;/li&gt;
&lt;li&gt;LLM 根据预先提供的图谱模式（Schema），将问题转换为 Cypher 查询。&lt;/li&gt;
&lt;li&gt;在图数据库上执行该查询，获取精确的结构化数据。&lt;/li&gt;
&lt;li&gt;(可选)将查询结果再次交由 LLM，生成通顺的自然语言答案。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;由于生成有效的 Cypher 查询是一项复杂的任务，通常使用性能较强的 LLM 来确保转换的准确性。通过这种方式，用户可以用最自然的方式与高度结构化的图数据进行交互，极大地降低了数据查询的门槛。&lt;/p&gt;
&lt;h2&gt;4. Text2SQL&lt;/h2&gt;
&lt;p&gt;这是结构化数据领域中一个常见的应用。在数据世界中，除了向量数据库能够处理的非结构化数据，关系型数据库（如 MySQL, PostgreSQL, SQLite）同样是存储和管理结构化数据的重点。文本到SQL（Text-to-SQL）正是为了打破人与结构化数据之间的语言障碍而生。它利用大语言模型（LLM）将用户的自然语言问题，直接翻译成可以在数据库上执行的SQL查询语句。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 进阶：Middleware</title><link>https://owen571.top/posts/study/langchain/10-middleware/</link><guid isPermaLink="true">https://owen571.top/posts/study/langchain/10-middleware/</guid><description>把 LangChain 的 Middleware 放回 Agent Loop 里理解：它到底拦在哪、能做什么，以及哪些 built-in middleware 最值得先掌握。</description><pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇更像 &lt;code&gt;Agents&lt;/code&gt; 的后续拆解。前面已经把模型、消息、工具、记忆、流式和结构化输出串起来了，这里再回头看 middleware，就更容易理解它到底是“插在循环的哪一层”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;1. 介绍&lt;/h2&gt;
&lt;p&gt;中间件提供了一种更精细地控制智能体内部运行逻辑的方式。中间件适用于以下场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;通过日志、分析和调试跟踪智能体行为。&lt;/li&gt;
&lt;li&gt;转换提示词、工具选择及输出格式。&lt;/li&gt;
&lt;li&gt;添加重试、降级方案和提前终止逻辑。&lt;/li&gt;
&lt;li&gt;应用速率限制、防护机制及个人身份信息检测&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只要在create_agent的时候传入中间件即可，代码示例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware, HumanInTheLoopMiddleware

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[...],
    middleware=[
        SummarizationMiddleware(...),
        HumanInTheLoopMiddleware(...)
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们知道，agent被invoke之后会进入一个loop，而中间件，就是在其中各个节点添加中间件。前面的学习中，其实我们已经多次用到中间件了。我们将普通的Agent Loop和带中间件的Agent Loop总结如下：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-11.DJUJNB-g.png&amp;amp;w=300&amp;amp;h=268&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-12.BeJW4sQw.png&amp;amp;w=500&amp;amp;h=560&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;2. Prebuilt middleware&lt;/h2&gt;
&lt;p&gt;LangChain 和 Deep Agents 为常见应用场景提供预构建中间件。每种中间件均可直接用于生产环境，并可根据你的具体需求进行配置。&lt;/p&gt;
&lt;p&gt;总结如下。为了方便查阅，我把表格和下面的示例代码做成了跳转关系：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;点击 “Middleware” 列：跳到本文档下方对应示例&lt;/li&gt;
&lt;li&gt;点击 “官方文档” 列：跳到 LangChain 官方 built-in middleware 页面对应章节&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Middleware&lt;/th&gt;
&lt;th&gt;中文解释&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;典型用法&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;th&gt;官方文档&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#summarization&quot;&gt;Summarization&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;对话摘要&lt;/td&gt;
&lt;td&gt;当上下文快接近 token 上限时，自动压缩历史对话，保留关键信息&lt;/td&gt;
&lt;td&gt;把早期多轮聊天总结成一段摘要，替换冗长历史消息&lt;/td&gt;
&lt;td&gt;长对话、客服、持续多轮 agent&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#summarization&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#human-in-the-loop&quot;&gt;Human-in-the-loop&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;人类介入审批&lt;/td&gt;
&lt;td&gt;在执行高风险动作前暂停，让人工确认是否继续&lt;/td&gt;
&lt;td&gt;调用删除文件、发邮件、转账、外部 API 写操作前先审批&lt;/td&gt;
&lt;td&gt;高风险工具调用、生产环境&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#human-in-the-loop&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#model-call-limit&quot;&gt;Model call limit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;模型调用次数限制&lt;/td&gt;
&lt;td&gt;限制一次任务中调用 LLM 的次数，防止死循环或费用失控&lt;/td&gt;
&lt;td&gt;设置最多调用模型 5 次，超出后直接终止&lt;/td&gt;
&lt;td&gt;成本控制、调试 agent 循环&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#model-call-limit&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#tool-call-limit&quot;&gt;Tool call limit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;工具调用次数限制&lt;/td&gt;
&lt;td&gt;限制 agent 调用工具的次数，避免无限试错&lt;/td&gt;
&lt;td&gt;限制搜索工具最多调 3 次、数据库查询最多调 5 次&lt;/td&gt;
&lt;td&gt;工具容易死循环、外部调用昂贵&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#tool-call-limit&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#model-fallback&quot;&gt;Model fallback&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;模型降级/回退&lt;/td&gt;
&lt;td&gt;主模型失败时，自动切换到备用模型继续执行&lt;/td&gt;
&lt;td&gt;先用 &lt;code&gt;gpt-4o&lt;/code&gt;，失败后回退到 &lt;code&gt;gpt-4o-mini&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;稳定性要求高、生产兜底&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#model-fallback&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#pii-detection&quot;&gt;PII detection&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;敏感信息检测&lt;/td&gt;
&lt;td&gt;检测输入/输出中是否包含个人敏感信息，并执行脱敏、拦截或告警&lt;/td&gt;
&lt;td&gt;识别手机号、身份证号、邮箱后自动打码&lt;/td&gt;
&lt;td&gt;隐私合规、企业内部系统&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#pii-detection&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#to-do-list&quot;&gt;To-do list&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;待办列表&lt;/td&gt;
&lt;td&gt;给 agent 增加任务分解、任务跟踪和完成状态记录能力&lt;/td&gt;
&lt;td&gt;把“大任务”拆成多个步骤，逐步完成并更新状态&lt;/td&gt;
&lt;td&gt;长流程任务、研究型 agent&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#llm-tool-selector&quot;&gt;LLM tool selector&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;工具预筛选器&lt;/td&gt;
&lt;td&gt;先用一个小模型判断哪些工具相关，再交给主模型决策&lt;/td&gt;
&lt;td&gt;先选出最可能需要的 3 个工具，减少主模型负担&lt;/td&gt;
&lt;td&gt;工具很多、路由复杂&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#llm-tool-selector&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#tool-retry&quot;&gt;Tool retry&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;工具重试&lt;/td&gt;
&lt;td&gt;工具调用失败时自动重试，并通常使用指数退避&lt;/td&gt;
&lt;td&gt;网络超时后 1 秒、2 秒、4 秒后再试&lt;/td&gt;
&lt;td&gt;外部 API 不稳定、偶发失败&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#tool-retry&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#model-retry&quot;&gt;Model retry&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;模型重试&lt;/td&gt;
&lt;td&gt;模型请求失败时自动重试，减少临时错误影响&lt;/td&gt;
&lt;td&gt;遇到超时、429、临时连接失败时自动再调一次&lt;/td&gt;
&lt;td&gt;模型 API 不稳定、网络波动&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#model-retry&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#llm-tool-emulator&quot;&gt;LLM tool emulator&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;工具模拟器&lt;/td&gt;
&lt;td&gt;用 LLM 模拟工具执行结果，便于测试 agent 流程&lt;/td&gt;
&lt;td&gt;不连真实数据库，而让模型假装返回查询结果&lt;/td&gt;
&lt;td&gt;本地调试、测试、演示&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#llm-tool-emulator&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#context-editing&quot;&gt;Context editing&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;上下文编辑&lt;/td&gt;
&lt;td&gt;动态裁剪、清理或重写上下文内容，避免上下文污染&lt;/td&gt;
&lt;td&gt;删掉无用 tool message，只保留关键结论&lt;/td&gt;
&lt;td&gt;长流程、多工具混杂场景&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#context-editing&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#shell-tool&quot;&gt;Shell tool&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Shell 工具&lt;/td&gt;
&lt;td&gt;给 agent 一个可持续的终端会话，让它执行命令&lt;/td&gt;
&lt;td&gt;运行 &lt;code&gt;ls&lt;/code&gt;、&lt;code&gt;python&lt;/code&gt;、&lt;code&gt;git status&lt;/code&gt; 等命令&lt;/td&gt;
&lt;td&gt;编码 agent、自动化运维&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#shell-tool&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#file-search&quot;&gt;File search&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;文件搜索&lt;/td&gt;
&lt;td&gt;提供文件级搜索能力，如 Glob、Grep、全文检索&lt;/td&gt;
&lt;td&gt;按文件名查找 &lt;code&gt;*.md&lt;/code&gt;，或全文搜索某个函数名&lt;/td&gt;
&lt;td&gt;代码库问答、文档检索&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#file-search&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#filesystem&quot;&gt;Filesystem&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;文件系统&lt;/td&gt;
&lt;td&gt;给 agent 提供读写文件能力，用于保存上下文、缓存或长期记忆&lt;/td&gt;
&lt;td&gt;把中间结果写入文件，下次继续读取&lt;/td&gt;
&lt;td&gt;持久化记忆、任务缓存&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#filesystem-middleware&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;#subagent&quot;&gt;Subagent&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;子代理&lt;/td&gt;
&lt;td&gt;允许 agent 派生多个子 agent 分工处理任务&lt;/td&gt;
&lt;td&gt;一个 agent 查资料，一个 agent 写总结，一个 agent 校验结果&lt;/td&gt;
&lt;td&gt;复杂任务拆分、并行处理&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/built-in#subagent&quot;&gt;链接&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;接下来，看看大概是怎么用的：&lt;/p&gt;
&lt;h3&gt;Summarization&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[your_weather_tool, your_calculator_tool],
    middleware=[
        SummarizationMiddleware(
            model=&quot;gpt-4.1-mini&quot;,
            trigger=(&quot;tokens&quot;, 4000),
            keep=(&quot;messages&quot;, 20),
        ),
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Human-in-the-loop&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver


def your_read_email_tool(email_id: str) -&amp;gt; str:
    &quot;&quot;&quot;Mock function to read an email by its ID.&quot;&quot;&quot;
    return f&quot;Email content for ID: {email_id}&quot;

def your_send_email_tool(recipient: str, subject: str, body: str) -&amp;gt; str:
    &quot;&quot;&quot;Mock function to send an email.&quot;&quot;&quot;
    return f&quot;Email sent to {recipient} with subject &apos;{subject}&apos;&quot;

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[your_read_email_tool, your_send_email_tool],
    checkpointer=InMemorySaver(),
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                &quot;your_send_email_tool&quot;: {
                    &quot;allowed_decisions&quot;: [&quot;approve&quot;, &quot;edit&quot;, &quot;reject&quot;],
                },
                &quot;your_read_email_tool&quot;: False,
            }
        ),
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Model call limit&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    checkpointer=InMemorySaver(),  # Required for thread limiting
    tools=[],
    middleware=[
        ModelCallLimitMiddleware(
            thread_limit=10,
            run_limit=5,
            exit_behavior=&quot;end&quot;,
        ),
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Tool call limit&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import ToolCallLimitMiddleware

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[search_tool, database_tool],
    middleware=[
        # Global limit
        ToolCallLimitMiddleware(thread_limit=20, run_limit=10),
        # Tool-specific limit
        ToolCallLimitMiddleware(
            tool_name=&quot;search&quot;,
            thread_limit=5,
            run_limit=3,
        ),
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Model fallback&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import ModelFallbackMiddleware

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[],
    middleware=[
        ModelFallbackMiddleware(
            &quot;gpt-4.1-mini&quot;,
            &quot;claude-3-5-sonnet-20241022&quot;,
        ),
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;PII detection&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import PIIMiddleware

agent = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[],
    middleware=[
        PIIMiddleware(&quot;email&quot;, strategy=&quot;redact&quot;, apply_to_input=True),
        PIIMiddleware(&quot;credit_card&quot;, strategy=&quot;mask&quot;, apply_to_input=True),
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
from langchain.agents.middleware import PIIMiddleware
import re


# Method 1: Regex pattern string
agent1 = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[],
    middleware=[
        PIIMiddleware(
            &quot;api_key&quot;,
            detector=r&quot;sk-[a-zA-Z0-9]{32}&quot;,
            strategy=&quot;block&quot;,
        ),
    ],
)

# Method 2: Compiled regex pattern
agent2 = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[],
    middleware=[
        PIIMiddleware(
            &quot;phone_number&quot;,
            detector=re.compile(r&quot;\+?\d{1,3}[\s.-]?\d{3,4}[\s.-]?\d{4}&quot;),
            strategy=&quot;mask&quot;,
        ),
    ],
)

# Method 3: Custom detector function
def detect_ssn(content: str) -&amp;gt; list[dict[str, str | int]]:
    &quot;&quot;&quot;Detect SSN with validation.

    Returns a list of dictionaries with &apos;text&apos;, &apos;start&apos;, and &apos;end&apos; keys.
    &quot;&quot;&quot;
    import re
    matches = []
    pattern = r&quot;\d{3}-\d{2}-\d{4}&quot;
    for match in re.finditer(pattern, content):
        ssn = match.group(0)
        # Validate: first 3 digits shouldn&apos;t be 000, 666, or 900-999
        first_three = int(ssn[:3])
        if first_three not in [0, 666] and not (900 &amp;lt;= first_three &amp;lt;= 999):
            matches.append({
                &quot;text&quot;: ssn,
                &quot;start&quot;: match.start(),
                &quot;end&quot;: match.end(),
            })
    return matches

agent3 = create_agent(
    model=&quot;gpt-4.1&quot;,
    tools=[],
    middleware=[
        PIIMiddleware(
            &quot;ssn&quot;,
            detector=detect_ssn,
            strategy=&quot;hash&quot;,
        ),
    ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. Custom middleware&lt;/h2&gt;
&lt;p&gt;自定义的中间件，就是前面说的几个钩子自定义函数，大概有这几类：&lt;/p&gt;
&lt;p&gt;Node-style:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;@before_agent - 智能体启动前执行（每次调用仅运行一次）&lt;/li&gt;
&lt;li&gt;@before_model - 每次调用模型前执行&lt;/li&gt;
&lt;li&gt;@after_model - 每次模型返回结果后执行&lt;/li&gt;
&lt;li&gt;@after_agent - 智能体执行完成后执行（每次调用仅运行一次）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wrap-style:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;@wrap_model_call - 用自定义逻辑包装每次模型调用&lt;/li&gt;
&lt;li&gt;@wrap_tool_call - 用自定义逻辑包装每次工具调用
Convenience:&lt;/li&gt;
&lt;li&gt;@dynamic_prompt - 生成动态系统提示词&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;关于这几个的调用，假设我们传入了三个中间件，执行流大概是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Before hooks run in order:
middleware1.before_agent()
middleware2.before_agent()
middleware3.before_agent()
Agent loop starts
middleware1.before_model()
middleware2.before_model()
middleware3.before_model()
Wrap hooks nest like function calls:
middleware1.wrap_model_call() → middleware2.wrap_model_call() → middleware3.wrap_model_call() → model
After hooks run in reverse order:
middleware3.after_model()
middleware2.after_model()
middleware1.after_model()
Agent loop ends
middleware3.after_agent()
middleware2.after_agent()
middleware1.after_agent()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. Agent jumps&lt;/h2&gt;
&lt;p&gt;我们可以使用&lt;code&gt;jump_to&lt;/code&gt;命令，提前退出中间件，有几个条跳转对象：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&apos;end&apos;：跳转到智能体执行结束（或首个after_agent钩子）&lt;/li&gt;
&lt;li&gt;&apos;tools&apos;：跳转到工具节点&lt;/li&gt;
&lt;li&gt;&apos;model&apos;：跳转到模型节点（或首个before_model钩子）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例子如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents.middleware import after_model, hook_config, AgentState
from langchain.messages import AIMessage
from langgraph.runtime import Runtime
from typing import Any


@after_model
@hook_config(can_jump_to=[&quot;end&quot;])
def check_for_blocked(state: AgentState, runtime: Runtime) -&amp;gt; dict[str, Any] | None:
    last_message = state[&quot;messages&quot;][-1]
    if &quot;BLOCKED&quot; in last_message.content:
        return {
            &quot;messages&quot;: [AIMessage(&quot;I cannot respond to that request.&quot;)],
            &quot;jump_to&quot;: &quot;end&quot;
        }
    return None
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 最好的用法&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;保持中间件职责专一 —— 每个中间件只做好一件事&lt;/li&gt;
&lt;li&gt;优雅处理错误 —— 避免中间件异常导致智能体崩溃&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;使用合适的钩子类型:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;节点式钩子用于顺序逻辑（日志记录、数据校验）&lt;/li&gt;
&lt;li&gt;包装式钩子用于控制流（重试、降级、缓存）&lt;/li&gt;
&lt;li&gt;清晰文档化所有自定义状态属性&lt;/li&gt;
&lt;li&gt;集成前对中间件进行独立单元测试&lt;/li&gt;
&lt;li&gt;考虑执行顺序 —— 关键中间件放在列表首位&lt;/li&gt;
&lt;li&gt;尽可能使用内置中间件&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>RAG 查询翻译：重写、分解与路由</title><link>https://owen571.top/posts/study/rag/11-rag-%E6%9F%A5%E8%AF%A2%E7%BF%BB%E8%AF%91-%E9%87%8D%E5%86%99%E4%B8%8E%E8%B7%AF%E7%94%B1/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/11-rag-%E6%9F%A5%E8%AF%A2%E7%BF%BB%E8%AF%91-%E9%87%8D%E5%86%99%E4%B8%8E%E8%B7%AF%E7%94%B1/</guid><description>用户问题往往不是最优检索输入，所以这一篇把查询翻译和查询路由放到一起，理解重写、拆分、HyDE 与路由各在解决什么问题。</description><pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;查询构建更像“把自然语言翻译成某种结构化查询”；这一篇进一步处理另一个问题：就算面对的还是普通文本检索，原始问题本身也未必是一个好 query。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 查询重构与分发&lt;/h1&gt;
&lt;h1&gt;一、引入&lt;/h1&gt;
&lt;p&gt;此前已经学习了如何从不同类型的数据源（如向量数据库、关系型数据库）中构建查询。然而，用户的原始问题往往不是最优的检索输入。它可能过于复杂、包含歧义，或者与文档的实际措辞存在偏差。为了解决这些问题，我们需要在检索之前对用户的查询进行“预处理”，这就是本节要探讨的查询重构与分发。&lt;/p&gt;
&lt;p&gt;这个阶段主要包含两个关键技术：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;查询翻译（Query Translation）：将用户的原始问题转换成一个或多个更适合检索的形式。&lt;/li&gt;
&lt;li&gt;查询路由（Query Routing）：根据问题的性质，将其智能地分发到最合适的数据源或检索器。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;本节将重点介绍几种主流的查询翻译技术，并简要讨论查询路由的概念。&lt;/p&gt;
&lt;h1&gt;二、查询翻译&lt;/h1&gt;
&lt;h2&gt;1. 提示工程&lt;/h2&gt;
&lt;p&gt;最直接的查询重构方法。通过精心设计的提示词（Prompt），可以引导 LLM 将用户的原始查询改写得更清晰、更具体，或者转换成一种更利于检索的叙述风格。比如，要求 LLM 直接分析用户的意图，并生成一个结构化（例如 JSON 格式）的指令，告诉我们的代码应该如何操作。&lt;/p&gt;
&lt;p&gt;举例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 使用大模型将自然语言转换为排序指令
prompt = f&quot;&quot;&quot;你是一个智能助手，请将用户的问题转换成一个用于排序视频的JSON指令。

你需要识别用户想要排序的字段和排序方向。
- 排序字段必须是 &apos;view_count&apos; (观看次数) 或 &apos;length&apos; (时长) 之一。
- 排序方向必须是 &apos;asc&apos; (升序) 或 &apos;desc&apos; (降序) 之一。

例如:
- &apos;时间最短的视频&apos; 或 &apos;哪个视频时间最短&apos; 应转换为 {{&quot;sort_by&quot;: &quot;length&quot;, &quot;order&quot;: &quot;asc&quot;}}
- &apos;播放量最高的视频&apos; 或 &apos;哪个视频最火&apos; 应转换为 {{&quot;sort_by&quot;: &quot;view_count&quot;, &quot;order&quot;: &quot;desc&quot;}}

请根据以下问题生成JSON指令:
原始问题: &quot;{query}&quot;

JSON指令:&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后我们在代码中调用LLM，解析其返回的JSON指令。&lt;/p&gt;
&lt;h2&gt;2. 多查询分解&lt;/h2&gt;
&lt;p&gt;当用户提出一个复杂的问题时，直接用整个问题去检索可能效果不佳，因为它可能包含多个子主题或意图。分解技术的核心思想是将这个复杂问题拆分成多个更简单、更具体的子问题。然后，系统分别对每个子问题进行检索，最后将所有检索到的结果合并、去重，形成一个更全面的上下文，再交给 LLM 生成最终答案。&lt;/p&gt;
&lt;p&gt;示例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原始问题：“在《流浪地球》中，刘慈欣对人工智能和未来社会结构有何看法？”&lt;/li&gt;
&lt;li&gt;分解后的子问题：
&lt;ul&gt;
&lt;li&gt;“《流浪地球》中描述的人工智能技术有哪些？”&lt;/li&gt;
&lt;li&gt;“《流浪地球》中描绘的未来社会是怎样的？”&lt;/li&gt;
&lt;li&gt;“刘慈欣关于人工智能的观点是什么？”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LangChain 提供了 MultiQueryRetriever 来完成这一过程。它在内部利用 LLM 将原始问题从不同角度分解成多个子问题，然后并行为每个子问题检索相关文档。最后，它将所有检索到的文档合并并去重，形成一个更全面的上下文，再传递给语言模型生成最终答案。通过这种策略，极大地丰富了检索结果，在有些应用中可以有效提升后续生成环节的质量。&lt;/p&gt;
&lt;h2&gt;3. 退步提示 （Step-Back Prompting）&lt;/h2&gt;
&lt;p&gt;退步提示是由 Google DeepMind 团队提出的一种旨在提升大语言模型推理能力的提示工程技巧。当面对一个细节繁多或过于具体的问题时，模型直接作答（即便是使用思维链）也容易出错。退步提示通过引导模型“退后一步”来解决这个问题。&lt;/p&gt;
&lt;p&gt;其核心流程分为两步：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;抽象化：首先，引导 LLM 从用户的原始具体问题中，生成一个更高层次、更概括的“退步问题”（Step-back Question）。这个退步问题旨在探寻原始问题背后的通用原理或核心概念。&lt;/li&gt;
&lt;li&gt;推理：接着，系统会先获取“退步问题”的答案（例如，一个物理定律、一段历史背景等），然后将这个通用原理作为上下文，再结合原始的具体问题，进行推理并生成最终答案。
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-36.SHe6tVdM.png&amp;amp;w=1100&amp;amp;h=767&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;示例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原始问题：“如果理想气体的温度增加2倍，体积增加8倍，其压力会如何变化？”&lt;/li&gt;
&lt;li&gt;退步问题：“这个问题背后的物理原理是什么？”&lt;/li&gt;
&lt;li&gt;推理过程：首先回答退步问题，得到“理想气体定律 PV=nRT”。然后基于这个定律，代入具体数值进行计算，最终得出压力变为原来的1/4。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. 假设性文档嵌入（HyDE）&lt;/h2&gt;
&lt;p&gt;假设性文档嵌入（Hypothetical Document Embeddings, HyDE）是一种无需微调即可显著提升向量检索质量的查询改写技术，由 Luyu Gao 等人在其论文中首次提出。其核心是解决一个普遍存在于检索任务中的难题：用户的查询（Query）通常简短、关键词有限，而数据库中存储的文档则内容详实、上下文丰富，两者在语义向量空间中可能存在“鸿沟”，导致直接用查询向量进行搜索效果不佳。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-37.DO9kuAz2.png&amp;amp;w=1600&amp;amp;h=900&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;HyDE 通过一种巧妙的方式来“绕过”这个问题：它不直接使用用户的原始查询，而是先利用一个生成式大语言模型（LLM）来生成一个“假设性”的、能够完美回答该查询的文档。然后，HyDE 将这个内容详实的假设性文档进行向量化，用其生成的向量去数据库中寻找与之最相似的真实文档。HyDE 的工作流程可以分为三个步骤：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;生成：当接收到用户查询时，首先调用一个生成式 LLM（例如，GPT-3.5）。提示该模型根据查询生成一个详细的、可能是理想答案的文档。这个文档不必完全符合事实，但它必须在语义上与一个好的答案高度相关。&lt;/li&gt;
&lt;li&gt;编码：将上一步生成的假设性文档输入到一个对比编码器（如 Contriever）中，将其转换为一个高维向量嵌入。这个向量在语义上代表了一个“理想答案”的位置。&lt;/li&gt;
&lt;li&gt;检索：使用这个假设性文档的向量，在向量数据库中执行相似性搜索，找出与这个“理想答案”最接近的真实文档。这些被检索出的文档将作为最终的上下文信息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;通过这种方式，HyDE 将困难的“查询到文档”的匹配问题，转化为了一个相对容易的“文档到文档”的匹配问题，从而提升检索的准确率。&lt;/p&gt;
&lt;h1&gt;三、查询路由&lt;/h1&gt;
&lt;p&gt;查询路由（Query Routing） 是用于优化复杂 RAG 系统的一项关键技术。当系统接入了多个不同的数据源或具备多种处理能力时，就需要一个“智能调度中心”来分析用户的查询，并动态选择最合适的处理路径。其本质是替代硬编码规则，通过语义理解将查询分发至最匹配的数据源、处理组件或提示模板，从而提升系统的效率与答案的准确性。&lt;/p&gt;
&lt;p&gt;查询路由有两种主流方法：&lt;/p&gt;
&lt;h2&gt;1. 基于LLM的意图识别&lt;/h2&gt;
&lt;p&gt;这是最灵活的方法。通过设计一个包含路由选项的提示词，让大语言模型（LLM）直接对用户的查询进行分类，并输出一个代表路由选择的标签。
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-38.BAu-JEMq.png&amp;amp;w=1473&amp;amp;h=748&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;实现流程：
&lt;ol&gt;
&lt;li&gt;定义清晰的路由选项（例如，数据源名称、功能分类）。&lt;/li&gt;
&lt;li&gt;LLM 分析查询并输出决策标签。&lt;/li&gt;
&lt;li&gt;代码根据标签调用相应的检索器或工具。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;该方法的核心在于构建一个“分类-分发”的流水线。&lt;/p&gt;
&lt;h2&gt;2. 嵌入相似性路由&lt;/h2&gt;
&lt;p&gt;这种方法不依赖 LLM 进行分类，延迟更低。它通过计算用户查询与预设的“路由示例语句”之间的向量嵌入相似度来做出决策。
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-39.D7jKZHmL.png&amp;amp;w=880&amp;amp;h=414&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>RAG 检索进阶：重排、压缩与校正</title><link>https://owen571.top/posts/study/rag/12-rag-%E6%A3%80%E7%B4%A2%E8%BF%9B%E9%98%B6-%E9%87%8D%E6%8E%92%E5%8E%8B%E7%BC%A9%E4%B8%8E%E6%A0%A1%E6%AD%A3/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/12-rag-%E6%A3%80%E7%B4%A2%E8%BF%9B%E9%98%B6-%E9%87%8D%E6%8E%92%E5%8E%8B%E7%BC%A9%E4%B8%8E%E6%A0%A1%E6%AD%A3/</guid><description>当召回已经有了以后，真正的生产级优化往往发生在后半程：重排、压缩和 corrective retrieval 都是在控制“给模型什么上下文”。</description><pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这篇可以看作“retrieval 后处理”专题。系统并不是把 top-k 原样喂给模型就结束了，很多质量差异其实发生在这一步之后。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 检索进阶&lt;/h1&gt;
&lt;p&gt;在基础的 RAG 流程中，依赖向量相似度从知识库中检索信息。不过，这种方法存在一些固有的局限性，例如最相关的文档不总是在检索结果的顶端，以及语义理解的偏差等。为了构建更强大、更精准的生产级 RAG 应用，需要引入更高级的检索技术。&lt;/p&gt;
&lt;h1&gt;一、重排序（Re-ranking）&lt;/h1&gt;
&lt;h2&gt;1. RRF（REciprocal Rank Fusion）&lt;/h2&gt;
&lt;p&gt;前面的章节已经介绍过RRF，它是一种简单而有效的零样本重排方法，不依赖于任何模型训练，而是纯粹基于文档在多个不同检索器（例如，一个稀疏检索器和一个密集检索器）结果列表中的排名来计算最终分数。但是如果只考虑排名信息，会忽略原始的相似度分数，可能丢失部分有用信息。&lt;/p&gt;
&lt;h2&gt;2. RankLLM/LLM-based Rerank&lt;/h2&gt;
&lt;p&gt;RankLLM 代表了一类直接利用大型语言模型本身来进行重排的方法。其基本逻辑非常直观：既然 LLM 最终要负责根据上下文来生成答案，那么为什么不直接让它来判断哪些上下文最相关呢？
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-40.PCcPwZjB.png&amp;amp;w=3272&amp;amp;h=934&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这种方法通过一个精心设计的提示词来实现。该提示词会包含用户的查询和一系列候选文档（通常是文档的摘要或关键部分），然后要求 LLM 以特定格式（如 JSON）输出一个排序后的文档列表，并给出每个文档的相关性分数。&lt;/p&gt;
&lt;p&gt;下面是一个提示的实例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;以下是一个文档列表，每个文档都有一个编号和摘要。同时提供一个问题。请根据问题，按相关性顺序列出您认为需要查阅的文档编号，并给出相关性分数（1-10分）。请不要包含与问题无关的文档。

示例格式:
文档 1: &amp;lt;文档1的摘要&amp;gt;
文档 2: &amp;lt;文档2的摘要&amp;gt;
...
文档 10: &amp;lt;文档10的摘要&amp;gt;

问题: &amp;lt;用户的问题&amp;gt;

回答:
Doc: 9, Relevance: 7
Doc: 3, Relevance: 4
Doc: 7, Relevance: 3
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. Cross-Encoder重排&lt;/h2&gt;
&lt;p&gt;Cross-Encoder（交叉编码器）能提供出色的重排精度2。它的工作原理是将查询（Query）和每个候选文档（Document）拼接成一个单一的输入（例如，[CLS] query [SEP] document [SEP]），然后将这个整体输入到一个预训练的 Transformer 模型（如 BERT）中，模型最终会输出一个单一的分数（通常在 0 到 1 之间），这个分数直接代表了文档与查询的相关性。&lt;/p&gt;
&lt;p&gt;它的工作流程如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初步检索：搜索引擎首先从知识库中召回一个初始的文档列表（例如，前 50 篇）。&lt;/li&gt;
&lt;li&gt;逐一评分：对于列表中的每一篇文档，系统都将其与原始查询配对，然后发送给 Cross-Encoder 模型。&lt;/li&gt;
&lt;li&gt;独立推理：模型对每个“查询-文档”对进行一次完整的、独立的推理计算，得出一个精确的相关性分数。&lt;/li&gt;
&lt;li&gt;返回重排结果：系统根据这些新的分数对文档列表进行重新排序，并将最终结果返回给用户。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. ColBERT重排&lt;/h2&gt;
&lt;p&gt;ColBERT（Contextualized Late Interaction over BERT）是一种创新的重排模型，它在 Cross-Encoder 的高精度和双编码器（Bi-Encoder）的高效率之间取得了平衡。采用了一种“后期交互”机制。&lt;/p&gt;
&lt;p&gt;其工作流程如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;独立编码：ColBERT 分别为查询（Query）和文档（Document）中的每个 Token 生成上下文相关的嵌入向量。这一步是独立完成的，可以预先计算并存储文档的向量，从而加快查询速度。&lt;/li&gt;
&lt;li&gt;后期交互：在查询时，模型会计算查询中每个 Token 的向量与文档中每个 Token 向量之间的最大相似度（MaxSim）。&lt;/li&gt;
&lt;li&gt;分数聚合：最后，将查询中所有 Token 得到的最大相似度分数相加，得到最终的相关性总分。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过这种方式，ColBERT 避免了将查询和文档拼接在一起进行昂贵的联合编码，同时又比单纯比较单个 [CLS] 向量的双编码器模型捕捉了更细粒度的词汇级交互信息。&lt;/p&gt;
&lt;h2&gt;5. 方法对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;RRF&lt;/th&gt;
&lt;th&gt;RankLLM&lt;/th&gt;
&lt;th&gt;Cross-Encoder&lt;/th&gt;
&lt;th&gt;ColBERT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心机制&lt;/td&gt;
&lt;td&gt;融合多个排名&lt;/td&gt;
&lt;td&gt;LLM 推理，生成排序列表&lt;/td&gt;
&lt;td&gt;联合编码查询与文档，计算单一相关分&lt;/td&gt;
&lt;td&gt;独立编码，后期交互&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;计算成本&lt;/td&gt;
&lt;td&gt;低（简单数学计算）&lt;/td&gt;
&lt;td&gt;中（API 费用与延迟）&lt;/td&gt;
&lt;td&gt;高（N 次模型推理）&lt;/td&gt;
&lt;td&gt;中（向量点积计算）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;交互粒度&lt;/td&gt;
&lt;td&gt;无（仅排名）&lt;/td&gt;
&lt;td&gt;概念/语义级&lt;/td&gt;
&lt;td&gt;句子级（Query-Doc Pair）&lt;/td&gt;
&lt;td&gt;Token 级&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;适用场景&lt;/td&gt;
&lt;td&gt;多路召回结果融合&lt;/td&gt;
&lt;td&gt;高价值语义理解场景&lt;/td&gt;
&lt;td&gt;Top-K 精排&lt;/td&gt;
&lt;td&gt;Top-K 重排&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;6. 拓展&lt;/h2&gt;
&lt;p&gt;主流reranker，本质上还是“Cross-Encoder 风格的 query-doc 相关性打分”这条路线。不过分成了两种常见实现，经典 Cross-Encoder reranker和LLM-based reranker / foundation-model reranker。&lt;/p&gt;
&lt;p&gt;我们可以实际看一下，允许用户自己搭建agent的Dify，在知识库设置界面的内容，学习一下成熟 RAG 产品的默认工程思路。
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-41.BynVbpip.png&amp;amp;w=1990&amp;amp;h=1892&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;首先，切块方式就有不同了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;General就是普通 chunking，检索块和返回块相同。&lt;/li&gt;
&lt;li&gt;Parent-Child是指子块用于检索，父块用于返回上下文。就是前面“上下文拓展”&lt;/li&gt;
&lt;li&gt;Q&amp;amp;A把文档显式拆成问答对。这说明在某些知识库里，最佳检索单元不是普通段落，而是结构化问答。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后，还让用户选择索引模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;高质量：调 embedding 模型做语义索引。Dify 提供三种检索设置：向量检索、全文检索和混合检索。&lt;/li&gt;
&lt;li&gt;经济：每个块用10个关键词进行检索，仅提供倒排索引方式。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后，Dify让用户选择检索配置。选择高质量的情况下，有以下几种选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;向量检索：向量化用户输入的问题并生成查询向量，然后将其与知识库中对应的文本向量进行比较，找到最相邻的分段。在向量检索中，可以设置Rerank模型。&lt;/li&gt;
&lt;li&gt;全文检索：索引文档中的所有词汇，允许用户查询任意词汇并返回包含这些词汇的文本片段。&lt;/li&gt;
&lt;li&gt;混合检索：同时执行全文检索和向量检索。这里我可以基于权重分配决定优先语义匹配还是关键字匹配（对应前面的加权线性组合）；或者直接用Rerank模型，先做混合召回再统一重排。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后，Dify还让你自己定义TopK和Score阈值（默认为0.5）。&lt;/p&gt;
&lt;h1&gt;二、压缩（Compression）&lt;/h1&gt;
&lt;p&gt;“压缩”技术旨在解决一个常见问题：初步检索到的文档块（Chunks）虽然整体上与查询相关，但可能包含大量无关的“噪音”文本。将这些未经处理的、冗长的上下文直接提供给 LLM，不仅会增加 API 调用的成本和延迟，还可能因为信息过载而降低最终生成答案的质量。&lt;/p&gt;
&lt;p&gt;压缩的目标就是对检索到的内容进行“压缩”和“提炼”，只保留与用户查询最直接相关的信息。这可以通过两种主要方式实现：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;内容提取：从文档中只抽出与查询相关的句子或段落。&lt;/li&gt;
&lt;li&gt;文档过滤：完全丢弃那些虽然被初步召回，但经过更精细判断后认为不相关的整个文档。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;1. ContextualCompressionRetriever&lt;/h2&gt;
&lt;p&gt;LangChain 提供了一个强大的组件 ContextualCompressionRetriever 来实现上下文压缩。它像一个包装器，包裹在基础的检索器（如 FAISS.as_retriever()）之上。当基础检索器返回文档后，ContextualCompressionRetriever 会使用一个指定的 DocumentCompressor 对这些文档进行处理，然后再返回给调用者。&lt;/p&gt;
&lt;p&gt;LangChain 内置了多种 DocumentCompressor：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LLMChainExtractor: 这是最直接的压缩方式。它会遍历每个文档，并利用一个 LLM Chain 来判断并提取出其中与查询相关的部分。这是一种“内容提取”。&lt;/li&gt;
&lt;li&gt;LLMChainFilter: 这种压缩器同样使用 LLM，但它做的是“文档过滤”。它会判断整个文档是否与查询相关，如果相关，则保留整个文档；如果不相关，则直接丢弃。&lt;/li&gt;
&lt;li&gt;EmbeddingsFilter: 这是一种更快速、成本更低的过滤方法。它会计算查询和每个文档的嵌入向量之间的相似度，只保留那些相似度超过预设阈值的文档。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 自定义重排器/压缩管道&lt;/h2&gt;
&lt;p&gt;此部分可以查询文档详细解决，这里不展开。&lt;/p&gt;
&lt;h1&gt;三、校正（Correcting）&lt;/h1&gt;
&lt;p&gt;传统的 RAG 流程有一个隐含的假设：检索到的文档总是与问题相关且包含正确答案。然而在现实世界中，检索系统可能会失败，返回不相关、过时或甚至完全错误的文档。如果将这些“有毒”的上下文直接喂给 LLM，就可能导致幻觉（Hallucination）或产生错误的回答。&lt;/p&gt;
&lt;p&gt;校正检索（Corrective-RAG, C-RAG） 正是为解决这一问题而提出的一种策略。思路是引入一个“自我反思”或“自我修正”的循环，在生成答案之前，对检索到的文档质量进行评估，并根据评估结果采取不同的行动。&lt;/p&gt;
&lt;p&gt;C-RAG 的工作流程可以概括为 “检索-评估-行动” 三个阶段：
&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-42.-vH09uPH.png&amp;amp;w=2000&amp;amp;h=1273&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;检索 (Retrieve) ：与标准 RAG 一样，首先根据用户查询从知识库中检索一组文档。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;评估 (Assess) ：这是 C-RAG 的关键步骤。如图所示，一个“检索评估器 (Retrieval Evaluator)”会判断每个文档与查询的相关性，并给出“正确 (Correct)”、“不正确 (Incorrect)”或“模糊 (Ambiguous)”的标签。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;行动 (Act) ：根据评估结果，系统会进入不同的知识修正与获取流程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果评估为“正确”：系统会进入“知识精炼 (Knowledge Refinement)”环节。如图，它会将原始文档分解成更小的知识片段 (strips)，过滤掉无关部分，然后重新组合成更精准、更聚焦的上下文，再送给大模型生成答案。&lt;/li&gt;
&lt;li&gt;如果评估为“不正确”：系统认为内部知识库无法回答问题，此时会触发“知识搜索 (Knowledge Searching)”。它会先对原始查询进行“查询重写 (Query Rewriting)”，生成一个更适合搜索引擎的查询，然后进行 Web 搜索，用外部信息来回答问题。&lt;/li&gt;
&lt;li&gt;如果评估为“模糊”：同样会触发“知识搜索”，但通常会直接使用原始查询进行 Web 搜索，以获取额外信息来辅助生成答案。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>RAG 评估：指标、工作流与工具</title><link>https://owen571.top/posts/study/rag/13-rag-%E8%AF%84%E4%BC%B0-%E6%8C%87%E6%A0%87%E5%B7%A5%E4%BD%9C%E6%B5%81%E4%B8%8E%E5%B7%A5%E5%85%B7/</link><guid isPermaLink="true">https://owen571.top/posts/study/rag/13-rag-%E8%AF%84%E4%BC%B0-%E6%8C%87%E6%A0%87%E5%B7%A5%E4%BD%9C%E6%B5%81%E4%B8%8E%E5%B7%A5%E5%85%B7/</guid><description>把 RAG 评估拆成一条清晰的工作流：先看检索，再看生成，再看工具；这样系统效果出问题时，才知道到底是哪一段出了偏差。</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;这一篇不再讲“怎么搭”，而是讲“怎么判断它到底有没有变好”。RAG 评估最重要的价值不只是打分，而是能把问题定位到检索层还是生成层。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;RAG - 评估&lt;/h1&gt;
&lt;p&gt;我们需要量化地追踪、迭代并提升RAG应用的性能，当系统出现幻觉或答非所问的时候，快速定位问题。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://owen571.top/_image/?href=%2F_astro%2Fimage-43.DvkPtiwY.png&amp;amp;w=1921&amp;amp;h=1099&amp;amp;f=webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;一、RAG评估三元组&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;上下文相关性 (Context Relevance)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;评估目标： 检索器（Retriever）的性能。&lt;/li&gt;
&lt;li&gt;核心问题： 检索到的上下文内容，是否与用户的查询（Query）高度相关？&lt;/li&gt;
&lt;li&gt;重要性： 检索是RAG应用在响应用户查询时的第一步。如果检索回来的上下文充满了噪声或无关信息，那么无论后续的生成模型多么强大，都没法做出正确答案。&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忠实度 / 可信度 (Faithfulness / Groundedness)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;评估目标： 生成器的可靠性。&lt;/li&gt;
&lt;li&gt;核心问题： 生成的答案是否完全基于所提供的上下文信息？&lt;/li&gt;
&lt;li&gt;重要性： 这个维度主要在于量化LLM的“幻觉”程度。一个高忠实度的回答意味着模型严格遵守了上下文，没有捏造或歪曲事实。如果忠实度得分低，说明LLM在回答时“自由发挥”过度，引入了外部知识或不实信息。&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;答案相关性 (Answer Relevance)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;评估目标： 系统的端到端（End-to-End）表现。&lt;/li&gt;
&lt;li&gt;核心问题： 最终生成的答案是否直接、完整且有效地回答了用户的原始问题？&lt;/li&gt;
&lt;li&gt;重要性： 这是用户最直观的感受。一个答案可能完全基于上下文（高忠实度），但如果它答非所问，或者只回答了问题的一部分，那么这个答案的相关性就很低。例如，当用户问“法国在哪里，首都是哪里？”，如果答案只是“法国在西欧”，那么虽然忠实度高，但答案相关性很低。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;二、评估工作流&lt;/h1&gt;
&lt;p&gt;虽然前面把评估拆成了三个维度，但真正落地的时候，其实可以顺着一条更自然的工作流去理解：先评估检索，再评估响应。因为RAG本质上就是“先检索，再生成”，前一环的问题会直接传递到后一环。&lt;/p&gt;
&lt;h2&gt;1. 检索评估&lt;/h2&gt;
&lt;p&gt;检索评估主要对应RAG三元组中的上下文相关性，更像是在做一次白盒测试。它不直接看最终回答，而是单独检查Retriever是否真的把相关文档找回来了。&lt;/p&gt;
&lt;p&gt;这一阶段通常需要一个标注数据集，里面至少要有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;查询（Query）&lt;/li&gt;
&lt;li&gt;每个查询对应的真实相关文档（Ground Truth）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有了这样的数据之后，就可以用传统信息检索里的指标来评估Retriever：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Precision@k&lt;/strong&gt;：看前k个结果里有多少是真的相关文档。它衡量的是“准不准”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
\text{Precision@k} = \frac{\text{检索到的 } k \text{ 个结果中的相关文档数}}{k}
$$&lt;/p&gt;
&lt;p&gt;其中，$k$ 表示只看前 $k$ 个检索结果。分子是这 $k$ 个结果里真正相关的文档数，分母就是被拿来评估的结果总数 $k$。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Recall@k&lt;/strong&gt;：看所有应该被找回的相关文档里，有多少真的被找回来了。它衡量的是“全不全”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
\text{Recall@k} = \frac{\text{检索到的 } k \text{ 个结果中的相关文档数}}{\text{数据集中所有相关的文档总数}}
$$&lt;/p&gt;
&lt;p&gt;这里的分子和 Precision@k 一样，仍然是前 $k$ 个结果中命中的相关文档数；但分母换成了数据集中这个查询对应的全部相关文档数，因此它衡量的是系统有没有把该找回来的内容尽量找全。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;F1&lt;/strong&gt;：Precision和Recall的调和平均，相当于在“准确”和“完整”之间取平衡。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
F_1 = 2 \cdot \frac{\text{Precision} \times \text{Recall}}{\text{Precision} + \text{Recall}}
$$&lt;/p&gt;
&lt;p&gt;其中，Precision 表示准确率，Recall 表示召回率。这个式子本质上是在对两者做调和平均，因此只要其中一个值很低，$F_1$ 就会被明显拉低。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MRR&lt;/strong&gt;（平均倒数排名，Mean Reciprocal Rank）：看第一个正确文档排得靠不靠前，适合用户通常只看第一个正确答案的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
\text{MRR} = \frac{1}{|Q|} \sum_{q=1}^{|Q|} \frac{1}{\text{rank}_q}
$$&lt;/p&gt;
&lt;p&gt;其中，$|Q|$ 是查询总数，$\text{rank}_q$ 是第 $q$ 个查询中第一个相关文档的排名。第一个相关文档排得越靠前，倒数值就越大，因此整体 MRR 也越高。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MAP&lt;/strong&gt;（平均准确率均值，Mean Average Precision）：综合考虑多个相关文档的排序质量，比单纯看第一个正确结果更全面。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
\text{MAP} = \frac{1}{|Q|} \sum_{q=1}^{|Q|} \text{AP}(q)
$$&lt;/p&gt;
&lt;p&gt;其中，$|Q|$ 是查询总数，$\text{AP}(q)$ 是第 $q$ 个查询的平均准确率。也就是说，MAP 先分别计算每个查询自己的排序质量，再对所有查询取平均。&lt;/p&gt;
&lt;p&gt;所以这一阶段的重点不是让模型回答得像不像人，而是先回答一个更基础的问题：知识到底有没有被正确召回。如果检索本身就错了，后面的生成质量通常也不会高。&lt;/p&gt;
&lt;h2&gt;2. 响应评估&lt;/h2&gt;
&lt;p&gt;响应评估主要对应RAG三元组中的忠实度和答案相关性，它更像端到端测试。这里不再只看Retriever，而是直接看用户最终拿到的答案质量。&lt;/p&gt;
&lt;p&gt;这部分通常围绕两个问题展开：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;忠实度：答案是不是严格基于检索到的上下文，有没有幻觉。&lt;/li&gt;
&lt;li&gt;答案相关性：答案有没有真正回答用户的问题，是否切题、完整。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常见的评估方法主要有两类。&lt;/p&gt;
&lt;p&gt;第一类是&lt;strong&gt;基于LLM的评估&lt;/strong&gt;。做法是再找一个高性能模型充当评委，让它来判断答案是否忠实、是否切题。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在忠实度评估里，通常会先把答案拆成若干条陈述，再逐条检查这些陈述能不能从上下文中得到支持。&lt;/li&gt;
&lt;li&gt;在答案相关性评估里，评委模型会同时看问题和答案，判断答案是否答非所问，或者虽然没幻觉但只回答了一半。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第二类是&lt;strong&gt;基于词汇重叠的经典指标&lt;/strong&gt;，前提是数据集中有标准答案。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ROUGE&lt;/strong&gt;（Recall-Oriented Understudy for Gisting Evaluation）更关注召回率，偏向评估内容有没有覆盖完整。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
\text{ROUGE-N} = \frac{\text{匹配的 } n\text{-gram 数量}}{\text{参考答案中 } n\text{-gram 的总数}}
$$&lt;/p&gt;
&lt;p&gt;其中，$n$ 表示连续词组的长度，例如 $n=1$ 时是 unigram，$n=2$ 时是 bigram。分子表示生成答案和参考答案之间匹配到的 $n$-gram 数量，分母表示参考答案里总共有多少个 $n$-gram，因此它本质上更偏召回率。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;BLEU&lt;/strong&gt;（Bilingual Evaluation Understudy）更关注精确率，同时会用长度惩罚避免答案过短。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
\text{BLEU} = \text{BP} \times \exp\left(\sum_{n=1}^{N} w_n \log p_n\right)
$$&lt;/p&gt;
&lt;p&gt;其中，$\text{BP}$ 是长度惩罚项（Brevity Penalty），用于防止模型只靠生成很短的句子拿高分；$p_n$ 是第 $n$ 阶 $n$-gram 的精确率，$w_n$ 是不同阶数精确率的权重，$N$ 表示最高统计到几阶 $n$-gram。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;METEOR&lt;/strong&gt;（Metric for Evaluation of Translation with Explicit ORdering）会同时考虑精确率、召回率，以及一定程度上的同义词和词干匹配，通常比BLEU更贴近人类判断。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$$
F_{\text{mean}} = \frac{P \times R}{\alpha P + (1-\alpha)R}
$$&lt;/p&gt;
&lt;p&gt;这里的 $P$ 表示精确率，$R$ 表示召回率，$\alpha$ 用来控制两者的相对权重。这个 $F_{\text{mean}}$ 可以看成是 Precision 和 Recall 的加权调和平均。&lt;/p&gt;
&lt;p&gt;$$
\text{METEOR} = F_{\text{mean}} \times (1 - \text{Penalty})
$$&lt;/p&gt;
&lt;p&gt;其中，$\text{Penalty}$ 是惩罚项，主要用于惩罚词序混乱或匹配片段过于零散的情况。因此 METEOR 不仅看匹配到了多少内容，也会考虑这些内容的组织是否自然。&lt;/p&gt;
&lt;p&gt;这两类方法各有特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;基于LLM的评估更懂语义，适合判断忠实度和答案是否真的切题，但成本更高，也可能带有评判模型本身的偏见。&lt;/li&gt;
&lt;li&gt;经典指标更快、更便宜，也更客观，但它们很难真正理解语义，只能看表面词汇是否重合。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，一个比较自然的评估工作流可以总结为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先用检索评估确认上下文有没有找对。&lt;/li&gt;
&lt;li&gt;再用响应评估判断答案有没有基于这些上下文正确生成。&lt;/li&gt;
&lt;li&gt;如果想大规模快速评测，可以先用ROUGE、BLEU这类便宜指标筛一遍；如果想看更真实的回答质量，再引入LLM-as-a-Judge做精细评估。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样做的好处是，当系统效果不好时，我们可以更容易定位问题到底出在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;检索没找准&lt;/li&gt;
&lt;li&gt;还是生成阶段出现了幻觉、答非所问&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;三、评估工具&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;LlamaIndex Evaluation：LlamaIndex Evaluation 是深度集成于LlamaIndex框架内的评估模块，专为使用该框架构建的RAG应用提供无缝的评估能力。&lt;/li&gt;
&lt;li&gt;RAGAS（RAG Assessment）：是一个独立的、专注于RAG的开源评估框架。提供了一套全面的指标来量化RAG管道的检索和生成两大核心环节的性能。其最显著的特色是支持无参考评估，即在许多场景下无需人工标注的“标准答案”即可进行评估，极大地降低了评估成本。&lt;/li&gt;
&lt;li&gt;Phoenix（Arize Phoenix）：是一个开源的LLM可观测性与评估平台。在RAG评估生态中，它主要扮演生产环境中的可视化分析与故障诊断引擎的角色。&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;工具&lt;/th&gt;
&lt;th&gt;核心机制&lt;/th&gt;
&lt;th&gt;独特技术&lt;/th&gt;
&lt;th&gt;典型应用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RAGAS&lt;/td&gt;
&lt;td&gt;LLM 驱动评估&lt;/td&gt;
&lt;td&gt;合成数据生成、无参考评估架构&lt;/td&gt;
&lt;td&gt;对比不同 RAG 策略、版本迭代后的性能回归测试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LlamaIndex&lt;/td&gt;
&lt;td&gt;嵌入式评估&lt;/td&gt;
&lt;td&gt;异步评估引擎、模块化 BaseEvaluator&lt;/td&gt;
&lt;td&gt;开发过程中快速验证单个组件或完整管道的效果&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phoenix&lt;/td&gt;
&lt;td&gt;追踪分析型&lt;/td&gt;
&lt;td&gt;分布式追踪、向量聚类分析算法&lt;/td&gt;
&lt;td&gt;生产环境监控、Bad Case 分析、数据漂移检测&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item></channel></rss>