<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>赵阳的技术博客</title><link>https://blog.canggo.com/</link><description>Recent content on 赵阳的技术博客</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><lastBuildDate>Mon, 20 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.canggo.com/index.xml" rel="self" type="application/rss+xml"/><item><title>智枢项目复盘：从 0 到 1 构建 RAG 知识管理平台</title><link>https://blog.canggo.com/p/rag-project/</link><pubDate>Wed, 18 Feb 2026 00:00:00 +0000</pubDate><guid>https://blog.canggo.com/p/rag-project/</guid><description>&lt;h2 id="项目背景">&lt;a href="#%e9%a1%b9%e7%9b%ae%e8%83%8c%e6%99%af" class="header-anchor">&lt;/a>项目背景
&lt;/h2>&lt;p>在企业场景中，知识分散在各种格式的文档中（PDF、Word、PPT 等），员工查找信息效率低下。传统的关键词搜索无法理解语义，经常找不到真正需要的内容。&lt;/p>
&lt;p>&lt;strong>智枢&lt;/strong> 是一个基于 RAG（Retrieval-Augmented Generation）架构的企业级智能知识管理平台，支持多格式文档上传、解析与向量化，用户可以通过自然语言进行智能问答。&lt;/p>

 &lt;blockquote>
 &lt;p>🔗 线上演示：&lt;a class="link" href="https://rag.canggo.com/" target="_blank" rel="noopener"
 >rag.canggo.com&lt;/a>&lt;/p>
 &lt;/blockquote>
&lt;h2 id="整体架构">&lt;a href="#%e6%95%b4%e4%bd%93%e6%9e%b6%e6%9e%84" class="header-anchor">&lt;/a>整体架构
&lt;/h2>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">用户 → Nginx → Spring Boot Application
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── MinIO（对象存储）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── Redis（缓存 / 分布式锁 / 会话管理）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── Kafka（异步消息队列）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── 消费者 → Apache Tika（文档解析）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ → 通义千问 Embedding（向量化）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ → Elasticsearch（向量索引）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── Elasticsearch（混合检索引擎）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── DeepSeek API（大语言模型）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── WebSocket（流式对话推送）
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="核心技术难点">&lt;a href="#%e6%a0%b8%e5%bf%83%e6%8a%80%e6%9c%af%e9%9a%be%e7%82%b9" class="header-anchor">&lt;/a>核心技术难点
&lt;/h2>&lt;h3 id="一大文件异步处理流水线">&lt;a href="#%e4%b8%80%e5%a4%a7%e6%96%87%e4%bb%b6%e5%bc%82%e6%ad%a5%e5%a4%84%e7%90%86%e6%b5%81%e6%b0%b4%e7%ba%bf" class="header-anchor">&lt;/a>一、大文件异步处理流水线
&lt;/h3>&lt;p>用户上传的文档可能非常大（几百 MB 甚至 1GB），同步处理会直接阻塞请求。&lt;/p>
&lt;p>&lt;strong>解决方案：&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>基于 &lt;strong>Redis BitMap&lt;/strong> 记录每个分片的上传状态，配合 MinIO 存储分片文件，实现断点续传&lt;/li>
&lt;li>通过文件 MD5 实现&lt;strong>秒传&lt;/strong>：相同文件直接复用，避免重复上传&lt;/li>
&lt;li>采用 MinIO &lt;code>composeObject&lt;/code> API 实现&lt;strong>服务端零拷贝合并&lt;/strong>，文件数据完全不经过 Java 服务，1GB 文件合并耗时缩短至 3s 内&lt;/li>
&lt;li>合并完成后通过 &lt;strong>Kafka 异步解耦&lt;/strong>后续的解析与向量化流程&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// Redis BitMap 标记分片状态&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">redisTemplate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">opsForValue&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="na">setBit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;upload:chunks:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunkIndex&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 零拷贝合并&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">try&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FileChannel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">source&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">FileChannel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunkPath&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">READ&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">transferTo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">source&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">size&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">targetChannel&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
 &lt;blockquote>
 &lt;p>关于大文件上传方案的完整设计，详见 &lt;a class="link" href="https://blog.canggo.com/p/file-upload-design/" >大文件断点续传方案设计&lt;/a>&lt;/p>
 &lt;/blockquote>
&lt;h3 id="二混合检索与-rag-增强">&lt;a href="#%e4%ba%8c%e6%b7%b7%e5%90%88%e6%a3%80%e7%b4%a2%e4%b8%8e-rag-%e5%a2%9e%e5%bc%ba" class="header-anchor">&lt;/a>二、混合检索与 RAG 增强
&lt;/h3>&lt;p>单纯的向量检索在短查询场景下表现不佳，单纯的关键词检索又无法理解语义。&lt;/p>
&lt;p>&lt;strong>方案：KNN 向量召回 + BM25 关键词重排序&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>通过通义千问 Embedding 模型将文档和查询都转为 2048 维向量&lt;/li>
&lt;li>&lt;strong>KNN 余弦相似度&lt;/strong>召回语义相关的候选文档&lt;/li>
&lt;li>&lt;strong>BM25&lt;/strong> 对候选文档进行关键词相关性重排序&lt;/li>
&lt;li>取 Top-K 结果拼接为上下文，构建增强 Prompt 送入大模型&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 混合检索：KNN 召回 + BM25 重排序&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">KnnSearchBuilder&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">knn&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">KnnSearchBuilder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;embedding_vector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">queryVector&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">10&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">100&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">Query&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bm25Query&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">QueryBuilders&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">match&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="na">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">userQuery&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="na">build&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="na">_toQuery&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="三流式对话与服务降级">&lt;a href="#%e4%b8%89%e6%b5%81%e5%bc%8f%e5%af%b9%e8%af%9d%e4%b8%8e%e6%9c%8d%e5%8a%a1%e9%99%8d%e7%ba%a7" class="header-anchor">&lt;/a>三、流式对话与服务降级
&lt;/h3>&lt;p>通过 &lt;strong>WebSocket&lt;/strong> 长连接集成 DeepSeek Stream API，实现&amp;quot;打字机式&amp;quot;逐字生成体验。&lt;/p>
&lt;p>&lt;strong>关键设计 — 自动降级机制：&lt;/strong> 当向量 API 不可用时，自动降级至纯 BM25 文本搜索，保障服务可用性。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="k">try&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">results&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vectorSearch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">catch&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">VectorApiException&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;向量检索异常，降级为文本搜索&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">results&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bm25Search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 自动降级&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="四细粒度权限管控">&lt;a href="#%e5%9b%9b%e7%bb%86%e7%b2%92%e5%ba%a6%e6%9d%83%e9%99%90%e7%ae%a1%e6%8e%a7" class="header-anchor">&lt;/a>四、细粒度权限管控
&lt;/h3>&lt;p>基于 Spring Security 实现 RBAC 权限模型，从角色、组织、文档多维度进行访问控制。&lt;/p>
&lt;p>采用 &lt;strong>JWT 双令牌架构&lt;/strong>（Access Token + Refresh Token），Access Token 短期有效（30 分钟），Refresh Token 长期有效（7 天），配合前端拦截器实现无感刷新。&lt;/p>
&lt;h2 id="收获与反思">&lt;a href="#%e6%94%b6%e8%8e%b7%e4%b8%8e%e5%8f%8d%e6%80%9d" class="header-anchor">&lt;/a>收获与反思
&lt;/h2>&lt;ol>
&lt;li>&lt;strong>异步设计&lt;/strong>是处理耗时任务的核心思路，Kafka 在解耦方面表现优秀&lt;/li>
&lt;li>&lt;strong>混合检索&lt;/strong>比单一检索方式效果好很多，融合策略的权重调优是关键&lt;/li>
&lt;li>&lt;strong>降级机制&lt;/strong>在依赖外部 API 的系统中必不可少&lt;/li>
&lt;li>如果重新做，会考虑引入更细粒度的文本切分策略（如语义分段），提升长文档的检索精度&lt;/li>
&lt;/ol></description></item><item><title>AI 智能体脚手架：多 Agent 编排框架的设计与实现</title><link>https://blog.canggo.com/p/agent-scaffold/</link><pubDate>Sun, 15 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog.canggo.com/p/agent-scaffold/</guid><description>&lt;h2 id="为什么要做这个框架">&lt;a href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e5%81%9a%e8%bf%99%e4%b8%aa%e6%a1%86%e6%9e%b6" class="header-anchor">&lt;/a>为什么要做这个框架？
&lt;/h2>&lt;p>随着大语言模型能力的提升，单个 Agent 已经无法满足复杂业务场景。多个 Agent 需要协同工作：一个负责意图理解，一个负责信息检索，一个负责内容生成&amp;hellip;&lt;/p>
&lt;p>目前主流的 Agent 框架（LangChain、AutoGen）大多基于 Python 生态，Java/Spring 生态缺乏成熟的多 Agent 编排方案。这个脚手架的目标是：&lt;strong>通过 YAML 配置就能快速搭建多 Agent 协同应用&lt;/strong>。&lt;/p>

 &lt;blockquote>
 &lt;p>🔗 线上演示：&lt;a class="link" href="https://agent.canggo.com" target="_blank" rel="noopener"
 >agent.canggo.com&lt;/a>&lt;/p>
 &lt;/blockquote>
&lt;h2 id="整体架构ddd-分层">&lt;a href="#%e6%95%b4%e4%bd%93%e6%9e%b6%e6%9e%84ddd-%e5%88%86%e5%b1%82" class="header-anchor">&lt;/a>整体架构（DDD 分层）
&lt;/h2>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">┌──────────────────────────────────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ Interface Layer（接口层） │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── REST API / SSE 流式端点 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├──────────────────────────────────────────────┤
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ Application Layer（应用层） │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── 编排执行服务 / 会话管理 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├──────────────────────────────────────────────┤
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ Domain Layer（领域层） │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── Agent 聚合根 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── 编排策略（顺序 / 并行 / 路由） │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── 工具 &amp;amp; 技能注册中心 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├──────────────────────────────────────────────┤
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ Infrastructure Layer（基础设施层） │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── Spring AI 适配器 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── Google ADK 适配器 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── MCP 工具连接器 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└──────────────────────────────────────────────┘
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="核心设计">&lt;a href="#%e6%a0%b8%e5%bf%83%e8%ae%be%e8%ae%a1" class="header-anchor">&lt;/a>核心设计
&lt;/h2>&lt;h3 id="一双框架协同spring-ai--google-adk">&lt;a href="#%e4%b8%80%e5%8f%8c%e6%a1%86%e6%9e%b6%e5%8d%8f%e5%90%8cspring-ai--google-adk" class="header-anchor">&lt;/a>一、双框架协同：Spring AI + Google ADK
&lt;/h3>&lt;p>这是框架最核心的设计挑战 —— 如何抹平两个 AI 框架的差异：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Google ADK&lt;/strong> 提供了优秀的 Agent 编排引擎和会话管理&lt;/li>
&lt;li>&lt;strong>Spring AI&lt;/strong> 提供了丰富的模型接入能力和工具执行机制&lt;/li>
&lt;/ul>
&lt;p>设计了适配层来桥接两者，上层复用 ADK 编排能力，下层复用 Spring AI 模型接入：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">SpringAiLlmAdapter&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">implements&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LlmClient&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">final&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ChatModel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chatModel&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nd">@Override&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ChatResponse&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Prompt&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chatModel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">toSpringAiPrompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">));&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nd">@Override&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Flux&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ChatResponse&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">stream&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Prompt&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chatModel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">stream&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">toSpringAiPrompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">));&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="二yaml-声明式编排">&lt;a href="#%e4%ba%8cyaml-%e5%a3%b0%e6%98%8e%e5%bc%8f%e7%bc%96%e6%8e%92" class="header-anchor">&lt;/a>二、YAML 声明式编排
&lt;/h3>&lt;p>通过 YAML 配置文件定义 Agent 及其编排关系，框架读取配置后自动完成 Bean 装配：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">agents&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">intent-analyzer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">deepseek-chat&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">system-prompt&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;你是一个意图分析器...&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">knowledge-retriever&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">deepseek-chat&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tools&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">search-tool, database-tool]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">orchestration&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sequential&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">agent&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">intent-analyzer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">agent&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">knowledge-retriever&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">condition&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;#{intent == &amp;#39;query&amp;#39;}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>基于&lt;strong>策略路由树&lt;/strong>解析 YAML，编排节点通过计数器遍历、类型路由与动态 Bean 回跳实现逻辑循环，支持顺序、并行、路由三种编排类型的&lt;strong>任意嵌套组合&lt;/strong>。&lt;/p>
&lt;h3 id="三mcp-工具统一接入">&lt;a href="#%e4%b8%89mcp-%e5%b7%a5%e5%85%b7%e7%bb%9f%e4%b8%80%e6%8e%a5%e5%85%a5" class="header-anchor">&lt;/a>三、MCP 工具统一接入
&lt;/h3>&lt;p>基于工厂模式统一对接三类 MCP 工具源：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>类型&lt;/th>
 &lt;th>说明&lt;/th>
 &lt;th>示例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Remote SSE&lt;/td>
 &lt;td>远程 MCP 服务&lt;/td>
 &lt;td>联网搜索、API 调用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local CLI&lt;/td>
 &lt;td>本地命令行工具&lt;/td>
 &lt;td>代码执行、文件操作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local Java&lt;/td>
 &lt;td>Java 组件&lt;/td>
 &lt;td>业务逻辑、数据查询&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>所有工具统一构建为技能列表注入模型，由框架自动完成多轮工具调用。&lt;/p>
&lt;h3 id="四响应式流设计">&lt;a href="#%e5%9b%9b%e5%93%8d%e5%ba%94%e5%bc%8f%e6%b5%81%e8%ae%be%e8%ae%a1" class="header-anchor">&lt;/a>四、响应式流设计
&lt;/h3>&lt;ul>
&lt;li>&lt;strong>流式模式&lt;/strong>：SSE + RxJava3 桥接 Reactor 响应式流，逐块推送&lt;/li>
&lt;li>&lt;strong>非流式模式&lt;/strong>：阻塞收集所有结果后统一返回&lt;/li>
&lt;li>两种模式&lt;strong>共享同一套装配产物&lt;/strong>，通过 Spring IOC 动态 Bean 注册实现装配与运行的解耦&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@GetMapping&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;/chat/stream&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">produces&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">MediaType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">TEXT_EVENT_STREAM_VALUE&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Flux&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ServerSentEvent&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">streamChat&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nd">@RequestParam&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">message&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">agentService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">streamExecute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">message&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ServerSentEvent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">builder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="na">build&lt;/span>&lt;span class="p">());&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="落地验证ai--drawio">&lt;a href="#%e8%90%bd%e5%9c%b0%e9%aa%8c%e8%af%81ai--drawio" class="header-anchor">&lt;/a>落地验证：AI + Draw.io
&lt;/h2>&lt;p>基于该脚手架落地了一个 &lt;strong>AI + Draw.io 交互式绘图应用&lt;/strong>，用户通过自然语言描述即可生成流程图。只需编写新的 YAML 配置和绘图工具实现，无需修改框架代码。这验证了框架的扩展性。&lt;/p>
&lt;h2 id="总结">&lt;a href="#%e6%80%bb%e7%bb%93" class="header-anchor">&lt;/a>总结
&lt;/h2>&lt;ul>
&lt;li>&lt;strong>抽象是关键&lt;/strong> — 好的适配层能让不同框架无缝协作&lt;/li>
&lt;li>&lt;strong>配置驱动 &amp;gt; 代码驱动&lt;/strong> — YAML 声明式配置大幅降低接入成本&lt;/li>
&lt;li>&lt;strong>响应式编程&lt;/strong> 在流式对话场景下体验远优于传统阻塞式方案&lt;/li>
&lt;/ul></description></item><item><title>你好世界 | 博客开张了</title><link>https://blog.canggo.com/p/%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C-%E5%8D%9A%E5%AE%A2%E5%BC%80%E5%BC%A0%E4%BA%86/</link><pubDate>Sun, 01 Feb 2026 00:00:00 +0000</pubDate><guid>https://blog.canggo.com/p/%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C-%E5%8D%9A%E5%AE%A2%E5%BC%80%E5%BC%A0%E4%BA%86/</guid><description>&lt;h2 id="博客开张-">&lt;a href="#%e5%8d%9a%e5%ae%a2%e5%bc%80%e5%bc%a0-" class="header-anchor">&lt;/a>博客开张 🎉
&lt;/h2>&lt;p>你好！我是赵阳，一名 Java 后端开发者，目前就读于郑州轻工业大学计算机科学与技术专业（2027 届）。&lt;/p>
&lt;p>这个博客主要用来记录我在后端开发和 AI 工程化方面的学习和实践，内容会围绕以下方向展开：&lt;/p>
&lt;h3 id="内容方向">&lt;a href="#%e5%86%85%e5%ae%b9%e6%96%b9%e5%90%91" class="header-anchor">&lt;/a>内容方向
&lt;/h3>&lt;ul>
&lt;li>&lt;strong>项目实战总结&lt;/strong> — 记录项目中的架构设计与技术难点&lt;/li>
&lt;li>&lt;strong>Java &amp;amp; Spring 生态&lt;/strong> — 框架原理、源码分析&lt;/li>
&lt;li>&lt;strong>数据库&lt;/strong> — MySQL 调优、Redis 原理与应用&lt;/li>
&lt;li>&lt;strong>中间件&lt;/strong> — Kafka、Elasticsearch 实战经验&lt;/li>
&lt;li>&lt;strong>AI 工程化&lt;/strong> — RAG、Agent 编排等 AI 应用落地&lt;/li>
&lt;li>&lt;strong>系统设计&lt;/strong> — 高并发、高可用架构思考&lt;/li>
&lt;/ul>
&lt;h3 id="我的项目">&lt;a href="#%e6%88%91%e7%9a%84%e9%a1%b9%e7%9b%ae" class="header-anchor">&lt;/a>我的项目
&lt;/h3>&lt;p>目前完成了两个比较完整的项目：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;a class="link" href="https://blog.canggo.com/p/rag-project/" >智枢 — 企业智能知识管理系统&lt;/a>&lt;/strong> — 基于 RAG 架构，支持多格式文档上传、向量化检索与智能问答&lt;/li>
&lt;li>&lt;strong>&lt;a class="link" href="https://blog.canggo.com/p/agent-scaffold/" >AI 智能体脚手架&lt;/a>&lt;/strong> — 基于 DDD 的多 Agent 编排框架，支持 YAML 声明式配置&lt;/li>
&lt;/ol>
&lt;p>持续更新中，欢迎交流 :)&lt;/p></description></item><item><title>AI 智能体脚手架：从 YAML 配置到多 Agent 协同的工程实践</title><link>https://blog.canggo.com/p/ai-agent-scaffold-deep-dive/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog.canggo.com/p/ai-agent-scaffold-deep-dive/</guid><description>&lt;p>这个项目要解决的问题是：怎么用一份 YAML 配置文件，就自动组装出一个能跑的、支持多智能体协同的 AI Agent？&lt;/p>
&lt;p>听起来简单，实际做起来涉及两套框架的适配、多模态消息的格式转换、会话上下文的维持、流式传输……坑还挺多的。&lt;/p>
&lt;p>本文把整个项目的核心设计从头到尾梳理一遍。&lt;/p>
&lt;h2 id="一整体架构ddd-分层">&lt;a href="#%e4%b8%80%e6%95%b4%e4%bd%93%e6%9e%b6%e6%9e%84ddd-%e5%88%86%e5%b1%82" class="header-anchor">&lt;/a>一、整体架构：DDD 分层
&lt;/h2>&lt;p>项目用的是 DDD 领域驱动设计，不是传统的 MVC。核心区别就一句话：&lt;strong>MVC 里服务层依赖数据访问层，是正向依赖；DDD 里领域层定义接口，基础设施层去实现，是依赖倒置。&lt;/strong>&lt;/p>
&lt;p>一共 6 个模块：&lt;/p>
&lt;p>┌─────────────────────────────────────────────────────┐
│ 启动层（app） │
│ Spring Boot 启动入口 + 监听器 + 线程池配置 │
├─────────────────────────────────────────────────────┤
│ 触发层（trigger） │
│ HTTP 控制器，接收前端请求，调用领域层 │
├─────────────────────────────────────────────────────┤
│ 接口契约层（api） │
│ 只定义接口 + DTO，不写任何实现 │
├─────────────────────────────────────────────────────┤
│ 领域层（domain）⭐ 核心 │
│ 装配链路、对话服务、适配器、消息转换器 │
├─────────────────────────────────────────────────────┤
│ 基础设施层（infrastructure） │
│ 实现领域层定义的接口（数据库、外部API等） │
├─────────────────────────────────────────────────────┤
│ 公共类型层（types） │
│ 枚举、异常类、常量 │
└─────────────────────────────────────────────────────┘&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">为什么接口契约层和触发层要分开？因为接口契约层定义的是**对外的 API 契约**，将来如果要从 HTTP 换成 RPC，只需要新增一个触发层实现，接口契约层完全不动。而且其他服务要调用我们，只需要依赖这一个契约模块，不会碰到任何实现代码。
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">## 二、双框架适配：为什么要同时用两个框架
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">这个项目同时用了 Google ADK 和 Spring AI，各取所长：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">| 框架 | 擅长的事 | 在项目中的角色 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">|------|---------|---------------|
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">| Google ADK | 多智能体编排、会话管理 | 上层，管调度 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">| Spring AI | 多模型接入、工具自动执行 | 下层，管调用 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">问题来了：两套框架的消息格式不一样。ADK 用的是 Content / Part 结构，Spring AI 用的是 Prompt / Message 结构。所以中间需要一个适配器来做转换。
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">这个适配器是整个项目的**枢纽**，它的核心方法做三件事：
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>SDK 格式的消息 ──→ 【消息转换器】 ──→ Spring AI 格式
↓
根据流式标志选择调用方式
流式 → stream()
非流式 → call()
↓
Spring AI 格式的响应 ──→ 【消息转换器】 ──→ SDK 格式返回&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-mysql" data-lang="mysql">&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">## MySpringAI 适配器
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">### 为什么需要它
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">LlmAgent&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">来自&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Google&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ADK&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">框架，它要求的模型接口是&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">LlmModel&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="err">（&lt;/span>&lt;span class="n">Google&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">自己定义的）。&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">ChatModel&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">来自&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Spring&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AI&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">框架，方法名、参数类型、返回类型全都不一样。直接把&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">ChatModel&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">塞给&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">LlmAgent&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="err">，编译都过不了。&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">MySpringAI&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">就是一个翻译官（适配器），对上实现&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Google&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ADK&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">的&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">LlmModel&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">接口，对下内部调用&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Spring&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AI&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">的&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="n">ChatModel&lt;/span>&lt;span class="o">`&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">方法：&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">```&lt;/span>&lt;span class="n">java&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">class&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">MySpringAI&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">implements&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LlmModel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">final&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ChatModel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chatModel&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">//&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">内部持有&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Spring&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AI&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">的模型&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LlmResponse&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">LlmRequest&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">Prompt&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">springPrompt&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">convertToSpringPrompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">//&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Google格式&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">→&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Spring格式&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ChatResponse&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chatModel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">springPrompt&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">//&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">用&lt;/span>&lt;span class="n">Spring&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AI真正调LLM&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">convertToAdkResponse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">//&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Spring格式&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">→&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Google格式&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="err">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="err">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>放在 &lt;code>patch&lt;/code> 包里，说明是对 Google ADK 官方适配器 &lt;code>SpringAI&lt;/code> 的补丁版本——可能修了 bug，可能加了自定义逻辑。&lt;/p>
&lt;h2 id="项目背景">&lt;a href="#%e9%a1%b9%e7%9b%ae%e8%83%8c%e6%99%af" class="header-anchor">&lt;/a>项目背景
&lt;/h2>&lt;p>这个项目要做的事情说起来就一句话：&lt;strong>从一份 YAML 配置文件出发，自动组装出一台能让 20 个 AI 智能体协作运转的机器，然后注册到 Spring 容器里等着被调用。&lt;/strong>&lt;/p>
&lt;p>听起来不复杂，但实际上组装过程涉及 API 连接创建、聊天模型构建、工具挂载、20 个基础 Agent 创建、8 个不同类型的工作流编排、还有嵌套引用……如果把这些逻辑全塞进一个大方法里，估计得写 500 行，改一个步骤就得在这 500 行里翻来翻去，以后想加个新步骤更是噩梦。&lt;/p>
&lt;p>所以项目用了&lt;strong>规则树&lt;/strong>这种设计模式，把整个组装过程拆成一个个独立的节点，像流水线一样一站接一站地执行。每个节点只管自己的事，干完活就告诉框架&amp;quot;下一站去哪&amp;quot;。&lt;/p>
&lt;!--more-->
&lt;h2 id="三装配链路从-yaml-到可运行的智能体">&lt;a href="#%e4%b8%89%e8%a3%85%e9%85%8d%e9%93%be%e8%b7%af%e4%bb%8e-yaml-%e5%88%b0%e5%8f%af%e8%bf%90%e8%a1%8c%e7%9a%84%e6%99%ba%e8%83%bd%e4%bd%93" class="header-anchor">&lt;/a>三、装配链路：从 YAML 到可运行的智能体
&lt;/h2>&lt;h3 id="31-触发阶段">&lt;a href="#31-%e8%a7%a6%e5%8f%91%e9%98%b6%e6%ae%b5" class="header-anchor">&lt;/a>3.1 触发阶段
&lt;/h3>&lt;p>Spring Boot 启动时把 YAML 里的属性自动映射到配置类。启动完成后发出一个&amp;quot;应用就绪&amp;quot;事件，监听器捕获到后取出所有智能体的配置列表，传给装配服务。&lt;/p>
&lt;h3 id="32-责任链执行">&lt;a href="#32-%e8%b4%a3%e4%bb%bb%e9%93%be%e6%89%a7%e8%a1%8c" class="header-anchor">&lt;/a>3.2 责任链执行
&lt;/h3>&lt;p>装配链路是一条 6 节点的责任链，每个节点只做一件事，通过&lt;strong>动态上下文对象&lt;/strong>传递中间产物：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">根节点（空节点，哨兵）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">API 节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 读取配置中的 URL 和密钥
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 创建 API 连接对象 → 存入上下文
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">模型节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 取出 API 连接 + 模型名称
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 遍历 MCP 工具列表（远程SSE/本地命令行/本地JavaBean）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 遍历 Skills 技能文件（文档+脚本）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 所有工具统一构建为 ToolCallback → 放入同一个列表
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 创建 ChatModel 对象 → 存入上下文
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">智能体节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 遍历配置中的每个智能体
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 取名称、描述、提示词、输出键
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 用适配器包装 ChatModel
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 创建 LLMAgent → 存入上下文的 Map 集合
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">编排节点（1 主节点 + 3 子节点，逻辑循环）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 详见 3.3 节
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">运行器节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 取出配置指定的入口智能体
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 创建 InMemoryRunner
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 包装成注册对象 → 动态注册为 Spring Bean
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 装配完成 ✓
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="33-编排节点的逻辑循环">&lt;a href="#33-%e7%bc%96%e6%8e%92%e8%8a%82%e7%82%b9%e7%9a%84%e9%80%bb%e8%be%91%e5%be%aa%e7%8e%af" class="header-anchor">&lt;/a>3.3 编排节点的逻辑循环
&lt;/h3>&lt;p>这是最复杂的节点。主节点和三个子节点之间形成了一种循环结构：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ┌──────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 主节点 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 计数器 &amp;gt;= 总数?│──── 是 ────→ 运行器节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 否，按类型 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 路由到子节点 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └──┬───┬───┬───┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ┌──────┘ │ └──────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼ ▼ ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 串行子节点 并行子节点 循环子节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └──────┬───┘──────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 创建完 → 存入集合 → 路由回主节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ┌──────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 主节点 │ ← 继续循环
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └──────────────┘
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>三个子节点的路由方法都指回主节点，但这里有个工程细节——&lt;strong>子节点不能用字段注入来引用主节点&lt;/strong>，否则会和主节点形成循环依赖，Spring 启动直接报错。所以子节点用的是运行时动态获取：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 子节点的路由方法&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nd">@Override&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">StrategyHandler&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">get&lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 不用 @Resource 注入，而是运行时从容器取&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 因为主节点持有子节点引用，子节点再注入主节点就循环了&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">getBean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;agentWorkflowNode&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>字段注入在 Bean 初始化阶段就要拿到引用，此时对方可能还没创建完；动态获取是在运行时去容器里取，这时候所有 Bean 早就创建好了。&lt;/p>
&lt;p>另外，编排智能体和普通的 LLM 智能体实现了&lt;strong>同一个接口&lt;/strong>，所以可以放在同一个 Map 里。先创建的编排智能体可以被后创建的引用——这就是嵌套编排的实现原理。比如先创建一个并行编排，后面的串行编排可以把它当子节点用。&lt;/p>
&lt;h3 id="34-动态上下文里装了什么">&lt;a href="#34-%e5%8a%a8%e6%80%81%e4%b8%8a%e4%b8%8b%e6%96%87%e9%87%8c%e8%a3%85%e4%ba%86%e4%bb%80%e4%b9%88" class="header-anchor">&lt;/a>3.4 动态上下文里装了什么
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">DynamicContext（共享黑板）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── API 连接对象 ← API 节点写入，模型节点读取
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── ChatModel 对象 ← 模型节点写入，智能体节点读取
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── 智能体 Map 集合 ← 智能体节点 + 编排节点共同写入
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── 编排步骤计数器 ← 编排节点用来控制循环
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── 当前编排配置 ← 编排节点用来判断类型和路由
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>每个智能体的装配都用一个全新的空上下文，多个智能体之间完全隔离。&lt;/p>
&lt;h3 id="35-为什么用责任链而不是写一个大方法">&lt;a href="#35-%e4%b8%ba%e4%bb%80%e4%b9%88%e7%94%a8%e8%b4%a3%e4%bb%bb%e9%93%be%e8%80%8c%e4%b8%8d%e6%98%af%e5%86%99%e4%b8%80%e4%b8%aa%e5%a4%a7%e6%96%b9%e6%b3%95" class="header-anchor">&lt;/a>3.5 为什么用责任链而不是写一个大方法
&lt;/h3>&lt;p>四个字：&lt;strong>开闭原则&lt;/strong>。&lt;/p>
&lt;p>想在装配流程中加一个新的处理步骤？新建一个节点类，改一下前后节点的路由指向，原有节点一行不动。如果是大方法，要在几百行代码中间找位置插入，还得处理变量作用域冲突。&lt;/p>
&lt;p>还有一个很实际的好处：如果第 5 个节点需要第 2 个节点产生的数据，直接从上下文里读就行。如果用节点间传参，这个数据就得在第 3、第 4 个节点一路透传过去，中间节点被迫携带自己根本不需要的参数。&lt;/p>
&lt;h2 id="谁是-bean-谁不是">&lt;a href="#%e8%b0%81%e6%98%af-bean-%e8%b0%81%e4%b8%8d%e6%98%af" class="header-anchor">&lt;/a>谁是 Bean 谁不是
&lt;/h2>&lt;p>这个点特别容易混淆，单独拎出来说清楚：&lt;/p>
&lt;p>&lt;strong>是 Bean 的：&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>AiAgentAutoConfigProperties&lt;/code>（&lt;code>@EnableConfigurationProperties&lt;/code> 注册的）&lt;/li>
&lt;li>所有 &lt;code>@Service&lt;/code> 标记的节点类（RootNode、AiApiNode 等等）&lt;/li>
&lt;li>&lt;code>AiAgentRegisterVO&lt;/code>（RunnerNode 里手动 &lt;code>registerBean()&lt;/code> 注册的，整条流水线唯一一次手动注册）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>不是 Bean 的：&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>AiAgentConfigTableVO&lt;/code>（只是配置类 Bean 里的一个字段值，不能被 &lt;code>@Resource&lt;/code> 注入）&lt;/li>
&lt;li>&lt;code>ArmoryCommandEntity&lt;/code>（每次调用时 new 出来的参数&amp;quot;信封&amp;quot;）&lt;/li>
&lt;li>&lt;code>DynamicContext&lt;/code>（每次调用时 new 出来的上下文&amp;quot;托盘&amp;quot;）&lt;/li>
&lt;li>流水线中间创建的所有对象（OpenAiApi、ChatModel、LlmAgent、LoopAgent 等等）&lt;/li>
&lt;/ul>
&lt;p>一句话记：&lt;strong>配置类&lt;/strong>（Properties）生出&lt;strong>配置数据&lt;/strong>（TableVO），配置数据装进&lt;strong>信封&lt;/strong>（CommandEntity），信封送进流水线。&lt;/p>
&lt;h2 id="mcpskills-工具创建细节">&lt;a href="#mcpskills-%e5%b7%a5%e5%85%b7%e5%88%9b%e5%bb%ba%e7%bb%86%e8%8a%82" class="header-anchor">&lt;/a>MCP/Skills 工具创建细节
&lt;/h2>&lt;h3 id="为什么需要工具">&lt;a href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e9%9c%80%e8%a6%81%e5%b7%a5%e5%85%b7" class="header-anchor">&lt;/a>为什么需要工具
&lt;/h3>&lt;p>模型只会&amp;quot;说话&amp;quot;，不会&amp;quot;做事&amp;quot;。工具让模型能调用外部能力——搜索网页、读取文件、调用本地 Java 方法等等。不管工具从哪来，最终都要变成 Spring AI 统一的 &lt;code>ToolCallback&lt;/code> 类型。&lt;/p>
&lt;h3 id="三种-mcp-工具类型">&lt;a href="#%e4%b8%89%e7%a7%8d-mcp-%e5%b7%a5%e5%85%b7%e7%b1%bb%e5%9e%8b" class="header-anchor">&lt;/a>三种 MCP 工具类型
&lt;/h3>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>类型&lt;/th>
 &lt;th>通信方式&lt;/th>
 &lt;th>比喻&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>SSE&lt;/td>
 &lt;td>HTTP 长连接到远程服务器&lt;/td>
 &lt;td>打电话给远程专家&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Stdio&lt;/td>
 &lt;td>启动本地子进程，通过标准输入输出通信&lt;/td>
 &lt;td>在电脑上开一个助手程序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local&lt;/td>
 &lt;td>直接从 Spring 容器取 Bean&lt;/td>
 &lt;td>找身边的同事帮忙&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>工厂 &lt;code>DefaultMcpClientFactory&lt;/code> 判断 &lt;code>ToolMcp&lt;/code> 对象里 &lt;code>sse&lt;/code>/&lt;code>local&lt;/code>/&lt;code>stdio&lt;/code> 三个字段哪个不为 null，就返回对应的创建服务——因为 YAML 的结构决定了每个 MCP 配置只会有一个子字段有值。&lt;/p>
&lt;h3 id="skills-工具">&lt;a href="#skills-%e5%b7%a5%e5%85%b7" class="header-anchor">&lt;/a>Skills 工具
&lt;/h3>&lt;p>Skills 是写在文件里的工具描述（比如 Markdown 格式），有两种加载方式：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;directory&amp;#34;&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">equals&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">type&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 从文件系统绝对路径加载——适合本地开发&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">SkillsTool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">builder&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="na">addSkillsDirectory&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="na">build&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;resource&amp;#34;&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">equals&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">type&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 从 classpath 资源路径加载——适合打包部署&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">SkillsTool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">builder&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="na">addSkillsResource&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ClassPathResource&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="na">build&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="一个-java-小知识toarraynew-toolcallback0">&lt;a href="#%e4%b8%80%e4%b8%aa-java-%e5%b0%8f%e7%9f%a5%e8%af%86toarraynew-toolcallback0" class="header-anchor">&lt;/a>一个 Java 小知识：&lt;code>toArray(new ToolCallback[0])&lt;/code>
&lt;/h3>&lt;p>这是 Java 里把 List 转成指定类型数组的惯用写法。传 &lt;code>new ToolCallback[0]&lt;/code> 不是为了用这个空数组装东西，纯粹是告诉 Java &amp;ldquo;我要的数组类型是 ToolCallback&amp;rdquo;——因为泛型在运行时会被擦除，List 不知道自己装的是什么类型。&lt;/p>
&lt;p>直觉上传正确大小的 &lt;code>new ToolCallback[list.size()]&lt;/code> 应该更快，但实际上传空数组反而更快：JIT 编译器能识别出 toArray 内部&amp;quot;创建数组后立刻被完全覆盖&amp;quot;的模式，直接跳过无意义的零初始化。而外部创建的数组 JIT 不敢优化，零初始化白白浪费了。&lt;/p>
&lt;h2 id="装配流水线的完整链路">&lt;a href="#%e8%a3%85%e9%85%8d%e6%b5%81%e6%b0%b4%e7%ba%bf%e7%9a%84%e5%ae%8c%e6%95%b4%e9%93%be%e8%b7%af" class="header-anchor">&lt;/a>装配流水线的完整链路
&lt;/h2>&lt;h3 id="整体结构">&lt;a href="#%e6%95%b4%e4%bd%93%e7%bb%93%e6%9e%84" class="header-anchor">&lt;/a>整体结构
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">YAML（原材料）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">第1站 RootNode → 纯入口，像链表头指针，什么都不做
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">第2站 AiApiNode → 创建 OpenAiApi（API连接对象）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">第3站 ChatModelNode → 创建 ChatModel（聊天模型 + MCP/Skills工具）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">第4站 AgentNode → 创建 20 个基础 LlmAgent，全部放入 agentGroup 货架
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">第5站 AgentWorkflowNode → 循环处理 8 个工作流配置，每次根据类型分发到子节点
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── LoopAgentNode → 处理 loop 类型 → 回到 AgentWorkflowNode
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── ParallelAgentNode → 处理 parallel 类型 → 回到 AgentWorkflowNode
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── SequentialAgentNode → 处理 sequential 类型 → 回到 AgentWorkflowNode
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">第6站 RunnerNode → 取出最终入口 Agent，包装成 InMemoryRunner，注册到 Spring
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">成品：AiAgentRegisterVO（包着 runner 的信封）
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="启动入口">&lt;a href="#%e5%90%af%e5%8a%a8%e5%85%a5%e5%8f%a3" class="header-anchor">&lt;/a>启动入口
&lt;/h3>&lt;p>Spring Boot 启动完成后，&lt;code>AiAgentAutoConfig&lt;/code> 监听到 &lt;code>ApplicationReadyEvent&lt;/code> 事件，自动触发装配：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@Configuration&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nd">@EnableConfigurationProperties&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">AiAgentAutoConfigProperties&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">class&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">AiAgentAutoConfig&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">implements&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ApplicationListener&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ApplicationReadyEvent&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nd">@Override&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">onApplicationEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ApplicationReadyEvent&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 从配置类（Bean）里取出配置数据（不是Bean，只是普通Java对象）&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 传给装配服务开始组装&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">armoryService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">acceptArmoryAgents&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ArrayList&lt;/span>&lt;span class="o">&amp;lt;&amp;gt;&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aiAgentAutoConfigProperties&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">getTables&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="na">values&lt;/span>&lt;span class="p">()));&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>这里有个容易混淆的点：&lt;code>AiAgentAutoConfigProperties&lt;/code> 是 Bean（被 &lt;code>@EnableConfigurationProperties&lt;/code> 注册的），但它里面的 &lt;code>AiAgentConfigTableVO&lt;/code> 不是 Bean——只是 Spring Boot 用 &lt;code>new&lt;/code> 创建出来填充字段的普通 Java 对象。就像&amp;quot;箱子在货架上有登记，但箱子里的东西没有单独登记&amp;quot;。&lt;/p>
&lt;h3 id="上下文dynamiccontext">&lt;a href="#%e4%b8%8a%e4%b8%8b%e6%96%87dynamiccontext" class="header-anchor">&lt;/a>上下文：DynamicContext
&lt;/h3>&lt;p>上下文是自己定义的，不是框架提供的。它就是一个跟着流水线走的&amp;quot;托盘&amp;quot;，每个节点都能往上面放东西、从上面拿东西：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">static&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">DynamicContext&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">OpenAiApi&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">openAiApi&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 第2站放入&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ChatModel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chatModel&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 第3站放入&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Map&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BaseAgent&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">agentGroup&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">HashMap&lt;/span>&lt;span class="o">&amp;lt;&amp;gt;&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 第4站开始持续放入&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AtomicInteger&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">currentStepIndex&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AtomicInteger&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 第5站的循环计数器&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AgentWorkflow&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">currentAgentWorkflow&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 第5站每次循环覆盖写入&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="第5站最复杂的-agentworkflownode">&lt;a href="#%e7%ac%ac5%e7%ab%99%e6%9c%80%e5%a4%8d%e6%9d%82%e7%9a%84-agentworkflownode" class="header-anchor">&lt;/a>第5站：最复杂的 AgentWorkflowNode
&lt;/h3>&lt;p>这个节点是整棵规则树里最精妙的部分。前面 4 站都是直线走下去，到这里开始出现&lt;strong>循环 + 分支&lt;/strong>。&lt;/p>
&lt;p>循环不是用 for/while 实现的，而是通过 &lt;code>currentStepIndex&lt;/code> 计数 + 子节点回指自身来形成逻辑上的循环：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// AgentWorkflowNode.doApply() 核心逻辑&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">getCurrentStepIndex&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">agentWorkflows&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">size&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 全部处理完了，跳到 RunnerNode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">setCurrentAgentWorkflow&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">router&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 取出当前索引对应的 workflow，放入上下文&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">setCurrentAgentWorkflow&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">agentWorkflows&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">getCurrentStepIndex&lt;/span>&lt;span class="p">()));&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">addCurrentStepIndex&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 索引 +1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">router&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>get()&lt;/code> 方法根据 workflow 的 type 字段决定分支走向：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">switch&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">node&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">case&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;loopAgentNode&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">loopAgentNode&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">case&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;parallelAgentNode&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">parallelAgentNode&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">case&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;sequentialAgentNode&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sequentialAgentNode&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">default&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runnerNode&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">};&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>三个子节点处理完后都回到 &lt;code>AgentWorkflowNode&lt;/code>——它们的 &lt;code>get()&lt;/code> 都写的 &lt;code>return getBean(&amp;quot;agentWorkflowNode&amp;quot;)&lt;/code>。用 &lt;code>getBean()&lt;/code> 而不是 &lt;code>@Resource&lt;/code> 是为了避免循环依赖。&lt;/p>
&lt;h3 id="嵌套的秘密agentgroup">&lt;a href="#%e5%b5%8c%e5%a5%97%e7%9a%84%e7%a7%98%e5%af%86agentgroup" class="header-anchor">&lt;/a>嵌套的秘密：agentGroup
&lt;/h3>&lt;p>所有 Agent（不管是基础的 LlmAgent 还是组装好的 LoopAgent/ParallelAgent/SequentialAgent）都放在同一个 &lt;code>agentGroup&lt;/code> 这个 Map 里。后面的 workflow 可以通过名字引用前面已经组装好的 workflow，因为它们&lt;strong>继承自同一个父类 BaseAgent&lt;/strong>，类型是兼容的。&lt;/p>
&lt;p>这就是为什么 YAML 里 workflow 的声明顺序必须正确——先被引用的必须先创建。如果顺序乱了，&lt;code>queryAgentList()&lt;/code> 按名字去 agentGroup 里取的时候会取不到，被直接跳过，导致组装出来的 Agent 树缺胳膊少腿。&lt;/p>
&lt;h2 id="规则树到底是什么">&lt;a href="#%e8%a7%84%e5%88%99%e6%a0%91%e5%88%b0%e5%ba%95%e6%98%af%e4%bb%80%e4%b9%88" class="header-anchor">&lt;/a>规则树到底是什么
&lt;/h2>&lt;h3 id="一句话定义">&lt;a href="#%e4%b8%80%e5%8f%a5%e8%af%9d%e5%ae%9a%e4%b9%89" class="header-anchor">&lt;/a>一句话定义
&lt;/h3>&lt;p>规则树 = &lt;strong>策略模式 + 责任链模式 + 组合模式&lt;/strong>的混合体。每个节点是一个独立策略，节点之间通过路由串成链，某些节点还能根据条件分叉出不同的分支，形成树形结构。&lt;/p>
&lt;h3 id="为什么叫树不叫链">&lt;a href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e5%8f%ab%e6%a0%91%e4%b8%8d%e5%8f%ab%e9%93%be" class="header-anchor">&lt;/a>为什么叫&amp;quot;树&amp;quot;不叫&amp;quot;链&amp;quot;
&lt;/h3>&lt;p>如果每个节点的下一站都是固定的，那就是一条链。但这个项目里有一个关键节点 &lt;code>AgentWorkflowNode&lt;/code>，它会&lt;strong>根据工作流的类型动态决定下一站去哪&lt;/strong>——是 loop 就去 LoopAgentNode，是 parallel 就去 ParallelAgentNode，是 sequential 就去 SequentialAgentNode。这就产生了分支，画出来就是一棵树。&lt;/p>
&lt;h3 id="为什么不写一个大方法">&lt;a href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e5%86%99%e4%b8%80%e4%b8%aa%e5%a4%a7%e6%96%b9%e6%b3%95" class="header-anchor">&lt;/a>为什么不写一个大方法
&lt;/h3>&lt;p>不是做不到，是&lt;strong>做完之后没法维护&lt;/strong>。任何用规则树实现的逻辑，用一个大方法都能实现，甚至可能 200 行就写完了。但规则树的价值不在于&amp;quot;能不能实现&amp;quot;，而在于&amp;quot;实现之后好不好改&amp;quot;。每个步骤独立成类，想改某一步就只改那一个类，想插入新步骤就写个新类改一下前后节点的指向，想删掉某个步骤就删类改指向——团队协作时多人改不同的节点类也不会冲突。&lt;/p>
&lt;h2 id="四装配与运行的衔接">&lt;a href="#%e5%9b%9b%e8%a3%85%e9%85%8d%e4%b8%8e%e8%bf%90%e8%a1%8c%e7%9a%84%e8%a1%94%e6%8e%a5" class="header-anchor">&lt;/a>四、装配与运行的衔接
&lt;/h2>&lt;p>装配阶段的最终产物是一个注册对象，里面包含智能体 ID、名称、描述和&lt;strong>内存运行器&lt;/strong>。&lt;/p>
&lt;p>内存运行器持有四样东西：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">InMemoryRunner
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── 入口智能体 （可以是 LLMAgent 也可以是编排智能体）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── 智能体名称
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── 工具列表 （所有 MCP + Skills）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── 会话服务 （管理用户会话的生命周期和对话历史）
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>装配链路最后一个节点通过 &lt;strong>Spring 动态 Bean 注册&lt;/strong>，以 agentId 为名称把注册对象注册到 IOC 容器：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 注册逻辑（伪代码）&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">容器中已存在同名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Bean&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">先移除旧的&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 支持热替换&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">注册新的&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">Bean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">agentId&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">注册对象&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>运行时用户请求过来，对话服务通过 agentId 从容器里取出注册对象，拿到运行器就能执行。装配不需要知道谁会调用，运行不需要知道怎么装配的——&lt;strong>完全解耦&lt;/strong>。&lt;/p>
&lt;p>为什么不直接用一个 Map 存？当然也行，但 Spring 容器帮你管理了对象的生命周期，全局可访问不需要到处传 Map 引用，热更新时&amp;quot;先移除再注册&amp;quot;的逻辑也更自然。&lt;/p>
&lt;h2 id="inmemoryrunner-的内部结构">&lt;a href="#inmemoryrunner-%e7%9a%84%e5%86%85%e9%83%a8%e7%bb%93%e6%9e%84" class="header-anchor">&lt;/a>InMemoryRunner 的内部结构
&lt;/h2>&lt;p>装配流水线的最终产物就是这个 runner。它里面装着 4 样东西：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>组成部分&lt;/th>
 &lt;th>装配阶段放进去的&lt;/th>
 &lt;th>运行阶段动态产生的&lt;/th>
 &lt;th>作用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Agent 树&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>固定不变的执行蓝图&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Session 管理器&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>管理每个用户的对话状态&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>应用名&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>日志标识 + Session 隔离&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>插件列表&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>扩展 runner 的能力（demo 里为空）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&amp;ldquo;InMemory&amp;quot;指的是 Session 数据存在内存里，应用重启后对话历史会丢失。&lt;/p>
&lt;h3 id="session-里的-outputkey-机制">&lt;a href="#session-%e9%87%8c%e7%9a%84-outputkey-%e6%9c%ba%e5%88%b6" class="header-anchor">&lt;/a>Session 里的 outputKey 机制
&lt;/h3>&lt;p>这是 Agent 之间传递数据的核心机制。每个 Agent 配置了 &lt;code>outputKey&lt;/code> 后，执行完毕的输出会被存到 &lt;code>session.state&lt;/code> 这个 Map 里。后续 Agent 的 instruction 里写 &lt;code>{request_brief}&lt;/code> 这样的占位符，ADK 框架会自动从 &lt;code>session.state&lt;/code> 里查找对应的 key 并替换：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">InputEchoAgent 执行完 → session.state.put(&amp;#34;request_brief&amp;#34;, &amp;#34;目标：分析AI趋势...&amp;#34;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">QuestionDecomposerAgent 即将执行：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> instruction 里有 {request_brief}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → 从 session.state 取出 &amp;#34;request_brief&amp;#34; 的值
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → 替换占位符后发给 LLM
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="五会话机制两层管理">&lt;a href="#%e4%ba%94%e4%bc%9a%e8%af%9d%e6%9c%ba%e5%88%b6%e4%b8%a4%e5%b1%82%e7%ae%a1%e7%90%86" class="header-anchor">&lt;/a>五、会话机制：两层管理
&lt;/h2>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">我们代码管理的（外层）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">┌────────────────────────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ConcurrentHashMap&amp;lt;用户ID, 会话ID&amp;gt; │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ &amp;#34;xiaofuge&amp;#34; → &amp;#34;a3f6-...&amp;#34; │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ &amp;#34;admin&amp;#34; → &amp;#34;b7e2-...&amp;#34; │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└────────────────────────────────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↑ 作用：快速查找用户有没有会话
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↑ 只存映射关系，不存聊天内容
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SDK 内部管理的（内层）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">┌────────────────────────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ SessionService │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ Session &amp;#34;a3f6-...&amp;#34; { │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ events: [ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ { role: user, text: &amp;#34;你好&amp;#34; } │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ { role: model, text: &amp;#34;你好！&amp;#34; }│
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ { role: user, text: &amp;#34;1+1&amp;#34; } │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ { role: model, text: &amp;#34;等于2&amp;#34; } │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ] │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ } │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└────────────────────────────────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↑ 作用：存储真正的对话历史
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>上下文维持的原理&lt;/strong>：每次发起对话传入用户 ID 和会话 ID，SDK 内部根据会话 ID 找到对应的会话对象，取出历史记录和新消息拼在一起发给大模型。大模型的 API 本身是无状态的——所谓的&amp;quot;记忆&amp;quot;就是每次把完整历史重新发一遍。&lt;/p>
&lt;p>外层 Map 创建会话时用的是原子操作，保证同一用户并发到达时只创建一次。&lt;/p>
&lt;p>&lt;strong>当前方案的局限&lt;/strong>：全在内存里，服务一重启就丢了。生产环境需要外层映射换 Redis（高频键值查询），内层会话历史存 MySQL（访问频率不高但数据重要）。还有个隐患是 Map 没有容量上限，用户越来越多会持续膨胀，长时间运行可能 OOM。&lt;/p>
&lt;h2 id="六工具调用注入触发执行">&lt;a href="#%e5%85%ad%e5%b7%a5%e5%85%b7%e8%b0%83%e7%94%a8%e6%b3%a8%e5%85%a5%e8%a7%a6%e5%8f%91%e6%89%a7%e8%a1%8c" class="header-anchor">&lt;/a>六、工具调用：注入、触发、执行
&lt;/h2>&lt;p>三个环节，&lt;strong>我们代码只负责第一个&lt;/strong>：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">【注入】我们的代码 ──────────────────────────────────
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 装配时遍历 MCP + Skills
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 构建 ToolCallback 列表
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 注入到 ChatModel 对象中
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">【触发】Spring AI 自动完成 ──────────────────────────
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 调用大模型时，把工具描述一起发过去
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 大模型自己决定是否调用
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 如果要调用，返回工具名称 + 参数
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">【执行】Spring AI 自动完成 ──────────────────────────
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 从 ToolCallback 列表中匹配对应工具
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 执行工具，拿到结果
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 把结果连同之前的消息再发给大模型
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↑ 这个过程可能多轮循环，直到大模型返回最终回答
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="mcp-和-skills-的区别">&lt;a href="#mcp-%e5%92%8c-skills-%e7%9a%84%e5%8c%ba%e5%88%ab" class="header-anchor">&lt;/a>MCP 和 Skills 的区别
&lt;/h3>&lt;p>触发机制完全一样——都是构建 ToolCallback，大模型根据描述决定调用，Spring AI 自动执行。区别在于回调内部做的事：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>MCP&lt;/th>
 &lt;th>Skills&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>本质&lt;/td>
 &lt;td>通过协议调用独立服务&lt;/td>
 &lt;td>读取本地文件返回内容&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工具逻辑在哪&lt;/td>
 &lt;td>远程服务 / 本地子进程 / JavaBean&lt;/td>
 &lt;td>文档 + 脚本，由大模型理解后使用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>类比&lt;/td>
 &lt;td>给智能体配了个&lt;strong>能干活的助手&lt;/strong>&lt;/td>
 &lt;td>给智能体发了本&lt;strong>操作手册&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>适用场景&lt;/td>
 &lt;td>重量级（数据库操作、代码执行）&lt;/td>
 &lt;td>轻量级（知识注入、简单脚本）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>有个容易误解的点：Skills 里的脚本不是大模型自己执行的。大模型读到&amp;quot;你需要执行某个脚本&amp;quot;后，如果有代码执行类的 MCP 工具，它会再发一次工具调用让 MCP 去执行。大模型自己执行不了任何东西。&lt;/p>
&lt;h2 id="七多模态消息构建与格式转换修复">&lt;a href="#%e4%b8%83%e5%a4%9a%e6%a8%a1%e6%80%81%e6%b6%88%e6%81%af%e6%9e%84%e5%bb%ba%e4%b8%8e%e6%a0%bc%e5%bc%8f%e8%bd%ac%e6%8d%a2%e4%bf%ae%e5%a4%8d" class="header-anchor">&lt;/a>七、多模态：消息构建与格式转换修复
&lt;/h2>&lt;h3 id="71-消息构建">&lt;a href="#71-%e6%b6%88%e6%81%af%e6%9e%84%e5%bb%ba" class="header-anchor">&lt;/a>7.1 消息构建
&lt;/h3>&lt;p>用户发多模态消息时，接收到的不再是字符串，而是一个实体类，里面三个列表：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ChatCommandEntity
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── texts: [&amp;#34;请分析这张图片&amp;#34;] → 转为文本类型 Part
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── inlineDatas: [{bytes, mimeType}] → 转为内联数据类型 Part
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── fileUris: [{url, mimeType}] → 转为 URI 类型 Part
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 合并为一个 Content 消息体
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Content 和 Part 是包含和被包含的关系。Part 一共 5 种类型：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Part 类型&lt;/th>
 &lt;th>携带数据&lt;/th>
 &lt;th>谁创建的&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>文本&lt;/td>
 &lt;td>纯文本字符串&lt;/td>
 &lt;td>我们的代码&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>内联数据&lt;/td>
 &lt;td>字节数组 + 类型标识&lt;/td>
 &lt;td>我们的代码&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>URI&lt;/td>
 &lt;td>远程地址 + 类型标识&lt;/td>
 &lt;td>我们的代码&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>函数调用&lt;/td>
 &lt;td>工具名称 + 参数&lt;/td>
 &lt;td>大模型返回的&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>函数响应&lt;/td>
 &lt;td>工具执行结果&lt;/td>
 &lt;td>Spring AI 自动生成的&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Content 有 3 种角色：user（用户消息）、model（模型回复）、system（系统指令/提示词）。&lt;/p>
&lt;h3 id="72-消息转换器的修复">&lt;a href="#72-%e6%b6%88%e6%81%af%e8%bd%ac%e6%8d%a2%e5%99%a8%e7%9a%84%e4%bf%ae%e5%a4%8d" class="header-anchor">&lt;/a>7.2 消息转换器的修复
&lt;/h3>&lt;p>框架默认的消息转换器有个 bug——转换时只处理文本，直接跳过了多媒体数据。强行转换会丢失全部图片信息。&lt;/p>
&lt;p>我们的修复策略是&lt;strong>最小侵入&lt;/strong>——继承框架的转换器，只重写有问题的那一步：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">原始消息（包含文本 + 图片）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├──① 先把多媒体数据提取出来暂存
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├──② 调用父类的转换方法（多媒体会丢失）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├──③ 把暂存的多媒体数据补回到转换结果的媒体字段
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Spring AI 格式的消息（文本 + 图片都在）
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>为什么不完全重写？因为父类的转换方法还处理了很多其他逻辑（角色映射、系统指令、历史拼接），全部复制一遍工作量大，而且父类更新时我们不会自动同步。只修复有问题的部分，其他完全复用父类，这样改动范围最小。&lt;/p>
&lt;h2 id="规则树的四个核心方法">&lt;a href="#%e8%a7%84%e5%88%99%e6%a0%91%e7%9a%84%e5%9b%9b%e4%b8%aa%e6%a0%b8%e5%bf%83%e6%96%b9%e6%b3%95" class="header-anchor">&lt;/a>规则树的四个核心方法
&lt;/h2>&lt;p>整棵规则树能自动一站接一站跑起来，全靠这 4 个方法的配合。这是最底层的机制，必须先搞明白这个，后面的所有代码才看得懂。&lt;/p>
&lt;h3 id="strategyhandler-接口框架层">&lt;a href="#strategyhandler-%e6%8e%a5%e5%8f%a3%e6%a1%86%e6%9e%b6%e5%b1%82" class="header-anchor">&lt;/a>StrategyHandler 接口（框架层）
&lt;/h3>&lt;p>框架提供了一个接口，定义了每个节点必须具备的能力：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">interface&lt;/span> &lt;span class="nc">StrategyHandler&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 对外暴露的执行入口——外界调用这个来启动节点&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">throws&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Exception&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 获取下一个节点——每个节点必须告诉框架&amp;#34;下一站是谁&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">StrategyHandler&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">throws&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Exception&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>三个泛型参数：&lt;code>T&lt;/code> 是输入参数类型，&lt;code>D&lt;/code> 是上下文类型，&lt;code>R&lt;/code> 是返回结果类型。&lt;/p>
&lt;h3 id="abstractmultithreadstrategyrouter-抽象类框架层">&lt;a href="#abstractmultithreadstrategyrouter-%e6%8a%bd%e8%b1%a1%e7%b1%bb%e6%a1%86%e6%9e%b6%e5%b1%82" class="header-anchor">&lt;/a>AbstractMultiThreadStrategyRouter 抽象类（框架层）
&lt;/h3>&lt;p>接口只说了&amp;quot;要做什么&amp;rdquo;，这个抽象类实现了通用逻辑：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">abstract&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">AbstractMultiThreadStrategyRouter&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">implements&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">StrategyHandler&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 子类必须实现的&amp;#34;干活&amp;#34;方法&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">protected&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">abstract&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">doApply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">throws&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Exception&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// apply() 的实现——框架帮你写好了&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nd">@Override&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">throws&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Exception&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">doApply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// ★ router()——找到下一个节点并执行它&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">protected&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">router&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">throws&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Exception&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">StrategyHandler&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">nextHandler&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nextHandler&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">nextHandler&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">requestParameter&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dynamicContext&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="四个方法的关系">&lt;a href="#%e5%9b%9b%e4%b8%aa%e6%96%b9%e6%b3%95%e7%9a%84%e5%85%b3%e7%b3%bb" class="header-anchor">&lt;/a>四个方法的关系
&lt;/h3>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>谁定义的&lt;/th>
 &lt;th>谁实现的&lt;/th>
 &lt;th>做什么&lt;/th>
 &lt;th>一句话记忆&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>apply()&lt;/code>&lt;/td>
 &lt;td>接口定义&lt;/td>
 &lt;td>&lt;strong>框架实现&lt;/strong>&lt;/td>
 &lt;td>启动一个节点的执行&lt;/td>
 &lt;td>&amp;ldquo;启动这个节点&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>doApply()&lt;/code>&lt;/td>
 &lt;td>抽象类定义&lt;/td>
 &lt;td>&lt;strong>你实现&lt;/strong>&lt;/td>
 &lt;td>这个节点的具体业务逻辑&lt;/td>
 &lt;td>&amp;ldquo;这个节点干什么&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>get()&lt;/code>&lt;/td>
 &lt;td>接口定义&lt;/td>
 &lt;td>&lt;strong>你实现&lt;/strong>&lt;/td>
 &lt;td>返回下一个节点是谁&lt;/td>
 &lt;td>&amp;ldquo;下一站去哪&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>router()&lt;/code>&lt;/td>
 &lt;td>抽象类定义&lt;/td>
 &lt;td>&lt;strong>框架实现&lt;/strong>&lt;/td>
 &lt;td>调用 get() 获取下一站，再调用它的 apply()&lt;/td>
 &lt;td>&amp;ldquo;帮我跳到下一站&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>所以写一个节点只需要写两个方法：&lt;code>doApply()&lt;/code>（干活）和 &lt;code>get()&lt;/code>（指路）。在 &lt;code>doApply()&lt;/code> 的最后一行调用 &lt;code>return router(参数, 上下文)&lt;/code> 就能自动跳到下一站。如果是最后一个节点，直接 return 结果就行，不调 &lt;code>router()&lt;/code>。&lt;/p>
&lt;h2 id="八流式与非流式">&lt;a href="#%e5%85%ab%e6%b5%81%e5%bc%8f%e4%b8%8e%e9%9d%9e%e6%b5%81%e5%bc%8f" class="header-anchor">&lt;/a>八、流式与非流式
&lt;/h2>&lt;h3 id="81-三个层面的区别">&lt;a href="#81-%e4%b8%89%e4%b8%aa%e5%b1%82%e9%9d%a2%e7%9a%84%e5%8c%ba%e5%88%ab" class="header-anchor">&lt;/a>8.1 三个层面的区别
&lt;/h3>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>层面&lt;/th>
 &lt;th>非流式&lt;/th>
 &lt;th>流式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>前端体验&lt;/td>
 &lt;td>等几秒后一次显示全部内容&lt;/td>
 &lt;td>文字像打字机一样逐步出现&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>控制器返回&lt;/td>
 &lt;td>普通 JSON 响应&lt;/td>
 &lt;td>ResponseBodyEmitter 流式发射器&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>底层调用&lt;/td>
 &lt;td>Spring AI 同步方法 call()&lt;/td>
 &lt;td>Spring AI 流式方法 stream()&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="82-流式发射器的作用">&lt;a href="#82-%e6%b5%81%e5%bc%8f%e5%8f%91%e5%b0%84%e5%99%a8%e7%9a%84%e4%bd%9c%e7%94%a8" class="header-anchor">&lt;/a>8.2 流式发射器的作用
&lt;/h3>&lt;p>正常的 HTTP 接口 return 之后连接就关闭了。流式发射器告诉 Spring 框架：&amp;ldquo;这个连接先别关，后面还有数据要推&amp;rdquo;。框架会设置响应头为流式传输格式，保持连接。&lt;/p>
&lt;p>超时时间设 3 分钟，是整个连接的总生存上限。复杂的智能体可能要多轮工具调用，一两分钟很正常，3 分钟给了比较宽裕的余量。超过就强制关闭，防止异常情况导致连接资源永远不释放。&lt;/p>
&lt;h3 id="83-runnerrunasync-的行为">&lt;a href="#83-runnerrunasync-%e7%9a%84%e8%a1%8c%e4%b8%ba" class="header-anchor">&lt;/a>8.3 runner.runAsync() 的行为
&lt;/h3>&lt;p>返回的是事件流对象，&lt;strong>延迟执行&lt;/strong>——调用后不会立即开始与大模型通信，只有对这个事件流进行订阅时才真正开始。不订阅就不执行，这是响应式编程的特点。&lt;/p>
&lt;p>还有个容易混淆的点：&lt;strong>控制层的流式/非流式和底层调用 LLM 的流式/非流式是独立的&lt;/strong>。控制层流式指的是返回给前端的方式，底层流式指的是调用大模型 API 的方式。底层大部分情况调的是流式，但也可能是同步——这也是为什么适配器内部即使底层是非流式，也要把响应包装成只有一个事件的事件流来返回，保持接口统一。&lt;/p>
&lt;h2 id="九错误处理的现状与不足">&lt;a href="#%e4%b9%9d%e9%94%99%e8%af%af%e5%a4%84%e7%90%86%e7%9a%84%e7%8e%b0%e7%8a%b6%e4%b8%8e%e4%b8%8d%e8%b6%b3" class="header-anchor">&lt;/a>九、错误处理的现状与不足
&lt;/h2>&lt;p>当前的错误处理是基础的捕获与传递模型：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;span class="lnt">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">LLM 调用异常
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── 记录到监控组件
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── 错误映射（统一格式）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── 包装成 RuntimeException 向上抛
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── 非流式 → 返回错误 JSON
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── 流式 → emitter.completeWithError() 中断连接
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>能用，但在生产环境中有三个明显的不足：&lt;/p>
&lt;p>&lt;strong>缺乏重试机制&lt;/strong>。网络抖动、大模型限流（429）这些很常见，当前失败一次就直接报错。应该针对可恢复异常做指数退避重试（隔 1s、2s、4s 重试 3 次）。&lt;/p>
&lt;p>&lt;strong>缺乏主备降级&lt;/strong>。主模型挂了就服务不可用。应该配备用模型，重试无果后自动切换。&lt;/p>
&lt;p>&lt;strong>工具调用失败会中断整个对话&lt;/strong>。MCP 远程服务挂了，异常一路向上抛，用户直接看到报错。更好的做法是拦截工具异常，把错误信息作为工具的返回值发给大模型，让大模型自己决定是换个工具还是告诉用户&amp;quot;这个服务暂时不可用&amp;quot;——这才是智能体该有的行为。&lt;/p>
&lt;h2 id="十多模态-http-接口设计">&lt;a href="#%e5%8d%81%e5%a4%9a%e6%a8%a1%e6%80%81-http-%e6%8e%a5%e5%8f%a3%e8%ae%be%e8%ae%a1" class="header-anchor">&lt;/a>十、多模态 HTTP 接口设计
&lt;/h2>&lt;p>当前 Controller 层的 &lt;code>chat&lt;/code> 和 &lt;code>chatStream&lt;/code> 两个接口，入参 &lt;code>ChatRequestDTO&lt;/code> 只有纯文本字段。Service 层的多模态方法没有暴露出来。&lt;/p>
&lt;p>如果要设计多模态接口，推荐&lt;strong>先上传后引用&lt;/strong>的方案：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">步骤&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="err">：前端调用文件上传接口&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">POST&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="n">api&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">v1&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">files&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">upload&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">multipart&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">form&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">返回：&lt;/span>&lt;span class="p">{&lt;/span> &lt;span class="s2">&amp;#34;fileUri&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://oss.xxx/dog.png&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;mimeType&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;image/png&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">步骤&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="err">：前端调用对话接口（&lt;/span>&lt;span class="n">JSON&lt;/span>&lt;span class="err">，不变）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">POST&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="n">api&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">v1&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">chat&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;agentId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;100003&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;userId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;admin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;请分析这张图片&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;files&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="s2">&amp;#34;fileUri&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://oss.xxx/dog.png&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;mimeType&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;image/png&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>为什么不把图片 Base64 编码塞进 JSON？体积膨胀 33%，大 JSON 解析吃内存容易 OOM。为什么不用 multipart 表单？因为现有的 &lt;code>chat&lt;/code> 和 &lt;code>chatStream&lt;/code> 两个接口都是 JSON 格式，如果其中一个改成表单格式，前端就要写两套请求逻辑，流式接口（SSE）配合表单上传更是别扭。&lt;/p>
&lt;p>先上传后引用的好处是：&lt;strong>两个现有接口都不需要破坏&lt;/strong>，只在 DTO 里加一个 files 字段就完成升级，依然是干净的 JSON。大文件传输压力可以剥离给 OSS，不拖垮智能体服务。&lt;/p>
&lt;h2 id="方案总结">&lt;a href="#%e6%96%b9%e6%a1%88%e6%80%bb%e7%bb%93" class="header-anchor">&lt;/a>方案总结
&lt;/h2>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>设计点&lt;/th>
 &lt;th>解决的问题&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>DDD 分层 + 依赖倒置&lt;/td>
 &lt;td>业务逻辑不受技术实现变化影响&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>双框架适配器&lt;/td>
 &lt;td>ADK 管编排 + Spring AI 管调用，各取所长&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>6 节点责任链&lt;/td>
 &lt;td>装配流程可扩展、可测试、可并行开发&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>编排节点逻辑循环 + 动态获取&lt;/td>
 &lt;td>支持嵌套编排，避免循环依赖&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>动态 Bean 注册&lt;/td>
 &lt;td>装配与运行完全解耦，支持热替换&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>两层会话管理&lt;/td>
 &lt;td>外层快速索引 + 内层存储历史&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消息转换器重写&lt;/td>
 &lt;td>最小侵入修复多模态丢失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流式发射器 + 事件流&lt;/td>
 &lt;td>打字机效果 + 统一的异步执行模型&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

 &lt;blockquote>
 &lt;p>智能体脚手架的核心思路：&lt;strong>配置驱动 + 职责分离&lt;/strong>。把复杂的装配过程拆成独立节点（责任链），把异构框架的差异封装在适配器里（桥接），把运行时的状态交给容器管理（IOC）。每一层只关心自己的事，加起来就是一个完整的智能体。&lt;/p>
 &lt;/blockquote></description></item><item><title>智枢项目 — 大文件异步流水线与 RAG 检索架构全拆解</title><link>https://blog.canggo.com/p/file-pipeline-and-rag/</link><pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog.canggo.com/p/file-pipeline-and-rag/</guid><description>&lt;h2 id="写在前面">&lt;a href="#%e5%86%99%e5%9c%a8%e5%89%8d%e9%9d%a2" class="header-anchor">&lt;/a>写在前面
&lt;/h2>&lt;p>这篇文章是对我个人项目**「智枢 — 企业智能知识管理系统」&lt;strong>中&lt;/strong>大文件异步流水线**模块的深度复盘。&lt;/p>
&lt;p>这个模块要解决的核心问题是：用户上传一个 1GB 的大文件（PDF / Word / Excel 等），系统要完成&lt;strong>分片上传 → 合并 → 解析 → 智能分块 → 向量化入库&lt;/strong>这整条链路，并且全程不能卡住用户、不能撑爆内存、不能丢数据。&lt;/p>
&lt;p>下面的内容，我会按照数据流经过系统的真实顺序，一步步拆解每个环节的设计思路和踩过的坑。&lt;/p>
&lt;hr>
&lt;h2 id="一1gb-文件上传从-15s-优化到-3s">&lt;a href="#%e4%b8%801gb-%e6%96%87%e4%bb%b6%e4%b8%8a%e4%bc%a0%e4%bb%8e-15s-%e4%bc%98%e5%8c%96%e5%88%b0-3s" class="header-anchor">&lt;/a>一、1GB 文件上传从 15s 优化到 3s
&lt;/h2>&lt;h3 id="11-前端怎么切片">&lt;a href="#11-%e5%89%8d%e7%ab%af%e6%80%8e%e4%b9%88%e5%88%87%e7%89%87" class="header-anchor">&lt;/a>1.1 前端怎么切片？
&lt;/h3>&lt;p>前端拿到文件后，利用 HTML5 的 &lt;code>Blob.slice()&lt;/code> 方法，按照固定大小（默认 5MB）进行循环切割。1GB 的文件会被切成大约 200 个分片。&lt;/p>
&lt;p>切片前，前端会开一个 &lt;strong>Web Worker&lt;/strong>（浏览器提供的后台独立线程），在后台计算整个文件的 MD5 值。这样做是为了不阻塞主线程，否则计算 1GB 文件的 MD5 会直接让网页卡死、失去响应。&lt;/p>

 &lt;blockquote>
 &lt;p>&lt;strong>为什么固定 5MB？&lt;/strong>&lt;/p>
 &lt;/blockquote>

 &lt;blockquote>
 &lt;p>因为 MinIO 兼容的 S3 协议有硬性要求：分片上传时，除了最后一个分片，其余分片&lt;strong>不能小于 5MB&lt;/strong>。同时 5MB 也是一个很好的平衡点——太小会导致 HTTP 请求过于频繁，太大则一旦网络波动重传成本太高。&lt;/p>
 &lt;/blockquote>
&lt;h3 id="12-后端怎么接收">&lt;a href="#12-%e5%90%8e%e7%ab%af%e6%80%8e%e4%b9%88%e6%8e%a5%e6%94%b6" class="header-anchor">&lt;/a>1.2 后端怎么接收？
&lt;/h3>&lt;p>后端的 &lt;code>/api/v1/upload/chunk&lt;/code> 接口接收到分片后，&lt;strong>不会把数据落到应用服务器的本地磁盘&lt;/strong>，而是直接通过流（Stream）透传写入 MinIO 对象存储的临时目录 &lt;code>chunks/{fileMd5}/{chunkIndex}&lt;/code> 中。&lt;/p>
&lt;p>写入成功后，做两件事：&lt;/p>
&lt;ol>
&lt;li>
&lt;p>在 Redis BitMap 中标记该分片已完成&lt;/p>
&lt;/li>
&lt;li>
&lt;p>在 MySQL 的 &lt;code>chunk_info&lt;/code> 表中记录分片的存储路径和 MD5&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>另外，在接收第一个分片时，系统会调用&lt;strong>秒传检测&lt;/strong>：查数据库和 MinIO，如果已经存在完全相同 MD5 的完整文件，直接复制元数据、返回 100% 进度，&lt;strong>耗时接近 0 秒&lt;/strong>。&lt;/p>
&lt;h3 id="13-后端怎么合并核心优化点">&lt;a href="#13-%e5%90%8e%e7%ab%af%e6%80%8e%e4%b9%88%e5%90%88%e5%b9%b6%e6%a0%b8%e5%bf%83%e4%bc%98%e5%8c%96%e7%82%b9" class="header-anchor">&lt;/a>1.3 后端怎么合并？（核心优化点）
&lt;/h3>&lt;p>传统合并方案是：后端把 200 个分片从 MinIO 全部下载到 JVM 内存里拼接成一个 1GB 的大文件，再重新上传回 MinIO。在千兆内网下，这个&amp;quot;一下一上&amp;quot;的双向传输至少要 &lt;strong>15 秒&lt;/strong>，还伴随着巨大的内存压力。&lt;/p>
&lt;p>&lt;strong>智枢的破局方案：MinIO 服务端零拷贝合并。&lt;/strong>&lt;/p>
&lt;p>系统使用了 MinIO 的 &lt;code>composeObject&lt;/code> API，本质上只是发送一条指令给 MinIO：&amp;ldquo;请把这 200 个分片在你内部拼成一个新文件&amp;rdquo;。MinIO 在自己的磁盘阵列上直接执行数据块的链接，&lt;strong>文件数据完全不经过我们的 Java 服务&lt;/strong>。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ComposeSource&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sources&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">partPaths&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">stream&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ComposeSource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">builder&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="na">bucket&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bucketName&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="na">object&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="na">build&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">collect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Collectors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">toList&lt;/span>&lt;span class="p">());&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">minioClient&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">composeObject&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ComposeObjectArgs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">builder&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">bucket&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bucketName&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">object&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;merged/&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fileMd5&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">sources&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sources&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">build&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>砍掉了 1GB 数据的内网双向传输开销后，合并耗时直接降到了 &lt;strong>3 秒以内&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="二redis-bitmap--1000-个分片仅占-125-字节">&lt;a href="#%e4%ba%8credis-bitmap--1000-%e4%b8%aa%e5%88%86%e7%89%87%e4%bb%85%e5%8d%a0-125-%e5%ad%97%e8%8a%82" class="header-anchor">&lt;/a>二、Redis BitMap —— 1000 个分片仅占 125 字节
&lt;/h2>&lt;h3 id="21-为什么用-bitmap">&lt;a href="#21-%e4%b8%ba%e4%bb%80%e4%b9%88%e7%94%a8-bitmap" class="header-anchor">&lt;/a>2.1 为什么用 BitMap？
&lt;/h3>&lt;p>断点续传需要高频地记录和查询&amp;quot;哪个分片传了，哪个没传&amp;quot;。如果用 Redis 的 List 或 Set 来存，每个分片序号至少占几十字节，1000 个分片可能要占好几 KB。&lt;/p>
&lt;p>而 BitMap 的思路极其简洁：&lt;strong>一个分片的状态 = 一个 Bit&lt;/strong>。0 表示未上传，1 表示已上传。&lt;/p>
&lt;h3 id="22-具体怎么记录">&lt;a href="#22-%e5%85%b7%e4%bd%93%e6%80%8e%e4%b9%88%e8%ae%b0%e5%bd%95" class="header-anchor">&lt;/a>2.2 具体怎么记录？
&lt;/h3>&lt;p>Key 的设计是 &lt;code>upload:{userId}:{fileMd5}&lt;/code>，确保每个用户每个文件的状态独立。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 标记第 5 号分片已上传&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">redisTemplate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">opsForValue&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="na">setBit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;upload:user123:abc123&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="23-125-字节怎么算的">&lt;a href="#23-125-%e5%ad%97%e8%8a%82%e6%80%8e%e4%b9%88%e7%ae%97%e7%9a%84" class="header-anchor">&lt;/a>2.3 &amp;ldquo;125 字节&amp;quot;怎么算的？
&lt;/h3>&lt;p>1000 个分片 = 1000 个 Bit。&lt;/p>
&lt;p>1 Byte = 8 Bits。&lt;/p>
&lt;p>&lt;strong>1000 ÷ 8 = 125 Bytes。&lt;/strong>&lt;/p>
&lt;p>这个空间压缩是恐怖的——相比用 Set 存 1000 个整数（大约几 KB），BitMap 把内存占用压缩了上百倍。&lt;/p>
&lt;h3 id="24-查询进度时怎么高效解析">&lt;a href="#24-%e6%9f%a5%e8%af%a2%e8%bf%9b%e5%ba%a6%e6%97%b6%e6%80%8e%e4%b9%88%e9%ab%98%e6%95%88%e8%a7%a3%e6%9e%90" class="header-anchor">&lt;/a>2.4 查询进度时怎么高效解析？
&lt;/h3>&lt;p>系统&lt;strong>不会&lt;/strong>循环发起 1000 次 Redis 请求。而是一次性把整个 BitMap 的字节数组拉取到 JVM 本地，然后通过位运算解析：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 一次性获取整个 BitMap&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="o">[]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bitmapData&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">redisTemplate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">execute&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">RedisCallback&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="o">[]&amp;gt;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">redisKey&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">getBytes&lt;/span>&lt;span class="p">());&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">});&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 在本地通过位运算检查每一位&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">boolean&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">isBitSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="o">[]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bitmapData&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bitIndex&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">byteIndex&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bitIndex&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">8&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bitPosition&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">7&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bitIndex&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">8&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bitmapData&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="n">byteIndex&lt;/span>&lt;span class="o">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bitPosition&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>CPU 指令级别的位运算，能在零点几毫秒内解析出所有分片的状态。&lt;/p>
&lt;hr>
&lt;h2 id="二断点续传redis-bitmap">&lt;a href="#%e4%ba%8c%e6%96%ad%e7%82%b9%e7%bb%ad%e4%bc%a0redis-bitmap" class="header-anchor">&lt;/a>二、断点续传：Redis BitMap
&lt;/h2>&lt;p>使用 Redis BitMap 记录每个分片的上传状态。空间效率极高 —— &lt;strong>1000 个分片只需 125 字节&lt;/strong>。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 标记分片已上传&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">markChunk&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">index&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">redisTemplate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">opsForValue&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">setBit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;upload:chunks:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">index&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 检查所有分片是否上传完毕&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">boolean&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">allUploaded&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">Long&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">count&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">redisTemplate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">RedisCallback&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Long&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">conn&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">bitCount&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="s">&amp;#34;upload:chunks:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="na">getBytes&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">count&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;amp;&amp;amp;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">count&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">==&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>网络中断后，客户端重新上传时，服务端返回已上传的分片列表，&lt;strong>客户端只需补传缺失部分&lt;/strong>。&lt;/p>
&lt;h2 id="三redis-挂了怎么办三重保障机制">&lt;a href="#%e4%b8%89redis-%e6%8c%82%e4%ba%86%e6%80%8e%e4%b9%88%e5%8a%9e%e4%b8%89%e9%87%8d%e4%bf%9d%e9%9a%9c%e6%9c%ba%e5%88%b6" class="header-anchor">&lt;/a>三、Redis 挂了怎么办？三重保障机制
&lt;/h2>&lt;p>Redis 在这个系统里只是&lt;strong>加速缓存层&lt;/strong>，不是数据安全的依赖。真正的数据安全靠的是 MySQL 和 MinIO 的双重持久化。&lt;/p>
&lt;h3 id="31-redis-数据丢了会怎样">&lt;a href="#31-redis-%e6%95%b0%e6%8d%ae%e4%b8%a2%e4%ba%86%e4%bc%9a%e6%80%8e%e6%a0%b7" class="header-anchor">&lt;/a>3.1 Redis 数据丢了会怎样？
&lt;/h3>&lt;p>前端查不到进度，会以为文件没传过，从第 0 个分片重新上传。但后端是&lt;strong>幂等的&lt;/strong>——它会去查 MySQL 和 MinIO，发现分片已经存在，就跳过真实的存储上传，只在 Redis 里补上标记。用户只会感觉进度条&amp;quot;闪退&amp;quot;了一下又迅速跑满，并没有产生冗余的网络传输。&lt;/p>
&lt;h3 id="32-合并时怎么保证不损坏">&lt;a href="#32-%e5%90%88%e5%b9%b6%e6%97%b6%e6%80%8e%e4%b9%88%e4%bf%9d%e8%af%81%e4%b8%8d%e6%8d%9f%e5%9d%8f" class="header-anchor">&lt;/a>3.2 合并时怎么保证不损坏？
&lt;/h3>&lt;p>合并接口&lt;strong>完全不信任 Redis&lt;/strong>。它直接去 MySQL 查所有分片记录，然后逐个调用 MinIO 的 &lt;code>statObject&lt;/code> 做物理探查，确认每个分片文件真实存在。只有 100% 确认后才执行合并。&lt;/p>
&lt;h3 id="33-修复孤儿记录">&lt;a href="#33-%e4%bf%ae%e5%a4%8d%e5%ad%a4%e5%84%bf%e8%ae%b0%e5%bd%95" class="header-anchor">&lt;/a>3.3 修复孤儿记录
&lt;/h3>&lt;p>代码中有一段自愈逻辑：如果 Redis 说&amp;quot;已上传&amp;quot;但 MySQL 没记录（或者反过来），系统会去 MinIO 确认真相，然后补齐缺失的那一方记录。&lt;/p>
&lt;hr>
&lt;h2 id="四kafka-异步解耦全流程">&lt;a href="#%e5%9b%9bkafka-%e5%bc%82%e6%ad%a5%e8%a7%a3%e8%80%a6%e5%85%a8%e6%b5%81%e7%a8%8b" class="header-anchor">&lt;/a>四、Kafka 异步解耦全流程
&lt;/h2>&lt;h3 id="41-消息是什么时候发的">&lt;a href="#41-%e6%b6%88%e6%81%af%e6%98%af%e4%bb%80%e4%b9%88%e6%97%b6%e5%80%99%e5%8f%91%e7%9a%84" class="header-anchor">&lt;/a>4.1 消息是什么时候发的？
&lt;/h3>&lt;p>这里有一个很容易搞混的细节。Kafka 消息&lt;strong>不是合并后立刻发的&lt;/strong>，真实的顺序是：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">① 合并分片 → 获得 1 小时有效的预签名下载链接
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">② 重新读取合并后的文件 → 预估向量化消耗 → 写入数据库
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">③ 构建 FileProcessingTask（把预签名链接塞进去）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">④ 通过 Kafka 事务性发送消息
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">⑤ 返回响应给前端
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="42-为什么用事务性发送">&lt;a href="#42-%e4%b8%ba%e4%bb%80%e4%b9%88%e7%94%a8%e4%ba%8b%e5%8a%a1%e6%80%a7%e5%8f%91%e9%80%81" class="header-anchor">&lt;/a>4.2 为什么用事务性发送？
&lt;/h3>&lt;p>普通的异步发送没有原子性保证。可能出现&amp;quot;数据库更新了但消息没发出去&amp;quot;的半成品状态。&lt;/p>
&lt;p>事务性发送（&lt;code>executeInTransaction&lt;/code>）保证闭包内的所有发送操作要么全部成功提交（消费者才能看到消息），要么全部回滚（消息被丢弃）。&lt;/p>
&lt;p>在智枢这种场景下，如果消息丢了但数据库标记为&amp;quot;已完成&amp;rdquo;，用户会看到文件上传成功了，但永远无法做问答检索——这种&amp;quot;静默失败&amp;quot;比直接报错更严重。&lt;/p>
&lt;h3 id="43-消费者处理流程">&lt;a href="#43-%e6%b6%88%e8%b4%b9%e8%80%85%e5%a4%84%e7%90%86%e6%b5%81%e7%a8%8b" class="header-anchor">&lt;/a>4.3 消费者处理流程
&lt;/h3>&lt;p>消费者从 Kafka 拉取到消息后，通过预签名链接从 MinIO 下载文件（&lt;strong>流式下载，不是一次性加载到内存&lt;/strong>），然后依次执行：&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>文件解析&lt;/strong>（Tika / PDFBox 提取纯文本）&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>智能分块&lt;/strong>（四级语义切分）&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>写入 MySQL&lt;/strong>（document_vectors 表）&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>调用向量化模型&lt;/strong>（生成高维向量）&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>写入 Elasticsearch&lt;/strong>（knowledge_base 索引）&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>回写实际消耗量&lt;/strong>（更新 file_upload 表的 actualEmbeddingTokens）&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h3 id="44-消费失败了消息会丢吗">&lt;a href="#44-%e6%b6%88%e8%b4%b9%e5%a4%b1%e8%b4%a5%e4%ba%86%e6%b6%88%e6%81%af%e4%bc%9a%e4%b8%a2%e5%90%97" class="header-anchor">&lt;/a>4.4 消费失败了消息会丢吗？
&lt;/h3>&lt;p>不会。消费者处理异常时会&lt;strong>主动向上抛出&lt;/strong>，触发两道防线：&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>自动重试：&lt;/strong> 默认最多重试 10 次，每次间隔递增&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>死信队列：&lt;/strong> 重试耗尽后，消息被转移到 &lt;code>原始主题名.DLT&lt;/code> 的死信主题中，并且保持&lt;strong>与原始消息相同的分区编号&lt;/strong>（方便排查来源）&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>死信消息不会被自动消费，需要人工排查修复后手动重新投递。&lt;/p>
&lt;h3 id="45-那个-1-小时的预签名链接是为消费者准备的">&lt;a href="#45-%e9%82%a3%e4%b8%aa-1-%e5%b0%8f%e6%97%b6%e7%9a%84%e9%a2%84%e7%ad%be%e5%90%8d%e9%93%be%e6%8e%a5%e6%98%af%e4%b8%ba%e6%b6%88%e8%b4%b9%e8%80%85%e5%87%86%e5%a4%87%e7%9a%84" class="header-anchor">&lt;/a>4.5 那个 1 小时的预签名链接是为消费者准备的
&lt;/h3>&lt;p>消费者通过这个临时链接下载文件，不需要知道 MinIO 的账号密码，降低了模块间的耦合。但如果 Kafka 消息积压超过 1 小时，链接就会过期，消费者会因为 403 而下载失败——这也是为什么需要合理设置 Kafka 分区数和消费者并发度。&lt;/p>
&lt;hr>
&lt;h2 id="五向量化消耗预估与预扣费">&lt;a href="#%e4%ba%94%e5%90%91%e9%87%8f%e5%8c%96%e6%b6%88%e8%80%97%e9%a2%84%e4%bc%b0%e4%b8%8e%e9%a2%84%e6%89%a3%e8%b4%b9" class="header-anchor">&lt;/a>五、向量化消耗预估与预扣费
&lt;/h2>&lt;h3 id="51-为什么要预估">&lt;a href="#51-%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e9%a2%84%e4%bc%b0" class="header-anchor">&lt;/a>5.1 为什么要预估？
&lt;/h3>&lt;p>因为 Kafka 消息是异步消费的，从发送到处理完成可能需要几分钟。如果不预扣，用户可能在这个时间窗口内连续上传 10 个大文件，每个都通过余额检查，但实际累计消耗远超余额。&lt;/p>
&lt;h3 id="52-怎么实现的">&lt;a href="#52-%e6%80%8e%e4%b9%88%e5%ae%9e%e7%8e%b0%e7%9a%84" class="header-anchor">&lt;/a>5.2 怎么实现的？
&lt;/h3>&lt;p>合并完成后、发 Kafka 之前，系统会重新读取合并后的文件流，走一遍与正式解析&lt;strong>完全相同的分块算法&lt;/strong>，但&lt;strong>只统计不存储&lt;/strong>：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">StreamingEstimateHandler&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kd">extends&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BodyContentHandler&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">long&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">estimatedTokens&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">0L&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">estimatedChunkCount&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">processParentChunk&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">childChunks&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">splitTextIntoChunksWithSemantics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">buffer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">toString&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunkSize&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">estimatedChunkCount&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">childChunks&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">size&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">estimatedTokens&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">usageQuotaService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">estimateEmbeddingTokens&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">childChunks&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">buffer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">setLength&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>预估完成后，系统先预扣用户余额。等消费者实际处理完成后，再用真实消耗量&amp;quot;多退少补&amp;quot;。&lt;/p>
&lt;p>如果预估消耗超过用户余额，合并接口会直接拒绝，Kafka 消息根本不会被发送。&lt;/p>
&lt;hr>
&lt;h2 id="三零拷贝合并">&lt;a href="#%e4%b8%89%e9%9b%b6%e6%8b%b7%e8%b4%9d%e5%90%88%e5%b9%b6" class="header-anchor">&lt;/a>三、零拷贝合并
&lt;/h2>&lt;p>&lt;strong>传统方案的问题：&lt;/strong> 读取所有分片到内存 → 写入目标文件。1GB 文件意味着至少 1GB 内存占用。&lt;/p>
&lt;p>&lt;strong>零拷贝方案：&lt;/strong> 利用 &lt;code>FileChannel.transferTo()&lt;/code> 在内核态完成数据拷贝，不经过用户态缓冲区：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">mergeChunks&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">totalChunks&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Path&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">target&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">try&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FileChannel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dest&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">FileChannel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">target&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">CREATE&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WRITE&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TRUNCATE_EXISTING&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">for&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">totalChunks&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">Path&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">getChunkPath&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">try&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FileChannel&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">src&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">FileChannel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">READ&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">src&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">transferTo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">src&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">size&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dest&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>效果：1GB 文件合并耗时从 15s+ 降至 3s 内&lt;/strong>，且内存占用几乎为零。&lt;/p>
&lt;h2 id="一秒传文件-md5-去重">&lt;a href="#%e4%b8%80%e7%a7%92%e4%bc%a0%e6%96%87%e4%bb%b6-md5-%e5%8e%bb%e9%87%8d" class="header-anchor">&lt;/a>一、秒传：文件 MD5 去重
&lt;/h2>&lt;p>客户端上传前先计算文件 MD5，服务端检查是否已存在相同文件：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UploadResult&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">checkFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">existingId&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">redisTemplate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">opsForValue&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;file:md5:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">existingId&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UploadResult&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">secondPass&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">existingId&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 秒传&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">Set&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Integer&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">uploaded&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">getUploadedChunks&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UploadResult&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">needUpload&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">uploaded&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 返回待上传分片&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="六jvm-内存熔断机制">&lt;a href="#%e5%85%adjvm-%e5%86%85%e5%ad%98%e7%86%94%e6%96%ad%e6%9c%ba%e5%88%b6" class="header-anchor">&lt;/a>六、JVM 内存熔断机制
&lt;/h2>&lt;h3 id="61-为什么需要">&lt;a href="#61-%e4%b8%ba%e4%bb%80%e4%b9%88%e9%9c%80%e8%a6%81" class="header-anchor">&lt;/a>6.1 为什么需要？
&lt;/h3>&lt;p>对于 PDF 文件，PDFBox 需要把整个文档树加载到内存中（为了按页提取和去页眉页脚），一个 500 页的 PDF 可能吃掉几百 MB 内存。如果同时处理多个大 PDF，堆内存很容易被撑爆。&lt;/p>
&lt;p>另外，即使是普通文档走 Tika 路线，底层的 POI 库在解析几十万行 Excel 时，也可能在内部缓存大量对象。&lt;/p>
&lt;h3 id="62-怎么实现的">&lt;a href="#62-%e6%80%8e%e4%b9%88%e5%ae%9e%e7%8e%b0%e7%9a%84" class="header-anchor">&lt;/a>6.2 怎么实现的？
&lt;/h3>&lt;p>在所有解析任务的入口处，有一道&amp;quot;内存安全门卫&amp;quot;：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">checkMemoryThreshold&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">Runtime&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">getRuntime&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kt">long&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">usedMemory&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">totalMemory&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">freeMemory&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kt">double&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">memoryUsage&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">double&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">usedMemory&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">maxMemory&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">memoryUsage&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">8&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 超过 80%&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">System&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">gc&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 尝试 GC 自救&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 重新检测&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">usedMemory&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">totalMemory&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">freeMemory&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">memoryUsage&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">double&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">usedMemory&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">maxMemory&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">memoryUsage&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">8&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 回收后依然超标&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">throw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">RuntimeException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;内存不足，拒绝处理&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>三步走：&lt;strong>探测 → 自救（GC）→ 果断拒绝&lt;/strong>。宁可让一条消息进入重试/死信，也绝不冒 OOM 崩溃的风险。&lt;/p>
&lt;hr>
&lt;h2 id="七双轨解析架构核心设计">&lt;a href="#%e4%b8%83%e5%8f%8c%e8%bd%a8%e8%a7%a3%e6%9e%90%e6%9e%b6%e6%9e%84%e6%a0%b8%e5%bf%83%e8%ae%be%e8%ae%a1" class="header-anchor">&lt;/a>七、双轨解析架构（核心设计）
&lt;/h2>&lt;p>这是整个大文件流水线中我最满意的一块设计。&lt;/p>
&lt;h3 id="71-为什么-pdf-不能用-tika">&lt;a href="#71-%e4%b8%ba%e4%bb%80%e4%b9%88-pdf-%e4%b8%8d%e8%83%bd%e7%94%a8-tika" class="header-anchor">&lt;/a>7.1 为什么 PDF 不能用 Tika？
&lt;/h3>&lt;p>Tika 当然能处理 PDF，但会引发两个灾难性后果：&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>页眉页脚污染语义：&lt;/strong> Tika 分不清正文和页眉，它会把&amp;quot;内部机密&amp;quot;、&amp;ldquo;第三章 架构设计&amp;quot;这种页眉页脚文本夹杂在正文中间，导致向量化后的数据质量极差。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>丢失页码信息：&lt;/strong> 企业知识库场景下，用户提问后系统必须告诉他&amp;quot;答案来自第 15 页&amp;rdquo;。Tika 的流式处理是一条线读到底的，根本不知道当前读到了第几页。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>所以我专门引入了 PDFBox，为 PDF 开辟了一条独立的精细化处理路线。&lt;/p>
&lt;h3 id="72-双轨是怎么分的">&lt;a href="#72-%e5%8f%8c%e8%bd%a8%e6%98%af%e6%80%8e%e4%b9%88%e5%88%86%e7%9a%84" class="header-anchor">&lt;/a>7.2 双轨是怎么分的？
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">parseAndSave&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fileMd5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">InputStream&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fileStream&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">...)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">checkMemoryThreshold&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 先做内存体检&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">isPdfDocument&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bufferedStream&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">parsePdfAndSave&lt;/span>&lt;span class="p">(...);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// PDF 专属路线&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 直接 return，不走 Tika！&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">// 非 PDF → Tika 通用路线&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">StreamingContentHandler&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">StreamingContentHandler&lt;/span>&lt;span class="p">(...);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">parser&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bufferedStream&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metadata&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>两条路线的核心区别：&lt;/strong>&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th style="text-align: left">维度&lt;/th>
 &lt;th style="text-align: left">PDF 路线（PDFBox）&lt;/th>
 &lt;th style="text-align: left">非 PDF 路线（Tika）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td style="text-align: left">解析方式&lt;/td>
 &lt;td style="text-align: left">加载完整文档树，按物理页遍历&lt;/td>
 &lt;td style="text-align: left">流式读取，1MB 缓冲区&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td style="text-align: left">页码信息&lt;/td>
 &lt;td style="text-align: left">✅ 精准保留每页页码&lt;/td>
 &lt;td style="text-align: left">❌ 无页码，存 null&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td style="text-align: left">页眉页脚处理&lt;/td>
 &lt;td style="text-align: left">✅ 专属去噪逻辑&lt;/td>
 &lt;td style="text-align: left">❌ 不需要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td style="text-align: left">内存占用&lt;/td>
 &lt;td style="text-align: left">较高（需要加载文档树）&lt;/td>
 &lt;td style="text-align: left">极低（永远只有 1MB）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td style="text-align: left">OOM 风险&lt;/td>
 &lt;td style="text-align: left">有，靠内存熔断兜底&lt;/td>
 &lt;td style="text-align: left">几乎没有&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="73-pdf-的页眉页脚是怎么去除的">&lt;a href="#73-pdf-%e7%9a%84%e9%a1%b5%e7%9c%89%e9%a1%b5%e8%84%9a%e6%98%af%e6%80%8e%e4%b9%88%e5%8e%bb%e9%99%a4%e7%9a%84" class="header-anchor">&lt;/a>7.3 PDF 的页眉页脚是怎么去除的？
&lt;/h3>&lt;p>思路非常巧妙：&lt;strong>如果某行文本在很多页的顶部或底部都重复出现，那它大概率就是页眉或页脚。&lt;/strong>&lt;/p>
&lt;p>具体步骤：&lt;/p>
&lt;ol>
&lt;li>
&lt;p>逐页提取文本，把每页按行切割&lt;/p>
&lt;/li>
&lt;li>
&lt;p>收集每页&lt;strong>顶部前 3 行&lt;/strong>和&lt;strong>底部后 3 行&lt;/strong>的有效文本&lt;/p>
&lt;/li>
&lt;li>
&lt;p>对这些边界行做&lt;strong>归一化处理&lt;/strong>——关键操作是把所有数字替换为 &lt;code>#&lt;/code>（这样&amp;quot;第 1 页&amp;quot;和&amp;quot;第 37 页&amp;quot;都变成&amp;quot;第 # 页&amp;quot;，被识别为同一行）&lt;/p>
&lt;/li>
&lt;li>
&lt;p>统计归一化后的行在所有页中出现的次数，超过阈值（2~3 次）的判定为页眉/页脚&lt;/p>
&lt;/li>
&lt;li>
&lt;p>从每页中剔除这些行&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h3 id="74-非-pdf-的流式处理具体怎么工作">&lt;a href="#74-%e9%9d%9e-pdf-%e7%9a%84%e6%b5%81%e5%bc%8f%e5%a4%84%e7%90%86%e5%85%b7%e4%bd%93%e6%80%8e%e4%b9%88%e5%b7%a5%e4%bd%9c" class="header-anchor">&lt;/a>7.4 非 PDF 的流式处理具体怎么工作？
&lt;/h3>&lt;p>Tika 的工作模式是**&amp;ldquo;推送模型&amp;rdquo;**：它每次从输入流中读取几 KB 的原始数据，解码后提取出纯文本，然后调用我们自定义的 &lt;code>characters&lt;/code> 回调方法。&lt;/p>
&lt;p>我们在回调方法里把文本累积到一个 &lt;code>StringBuilder&lt;/code> 缓冲区中，当缓冲区达到 1MB 时，就触发一次分块处理，然后立刻清空缓冲区：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@Override&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">characters&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">char&lt;/span>&lt;span class="o">[]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ch&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">start&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">length&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">buffer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ch&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">start&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">length&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">buffer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">length&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">parentChunkSize&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 1MB&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">processParentChunk&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>所以 JVM 内存中任何时刻最多只有约 1MB 的文本数据&lt;/strong>，无论原始文件是 100MB 还是 10GB。数据像流水一样经过 JVM，处理完就释放，永远不会蓄积。&lt;/p>
&lt;hr>
&lt;h2 id="八四级语义分块算法">&lt;a href="#%e5%85%ab%e5%9b%9b%e7%ba%a7%e8%af%ad%e4%b9%89%e5%88%86%e5%9d%97%e7%ae%97%e6%b3%95" class="header-anchor">&lt;/a>八、四级语义分块算法
&lt;/h2>&lt;p>PDF 和非 PDF 在前半段的&amp;quot;解析&amp;quot;走的是两条不同的路，但在后半段的&amp;quot;分块和入库&amp;quot;时，它们殊途同归，汇聚到同一个方法：&lt;code>splitTextIntoChunksWithSemantics&lt;/code>。&lt;/p>
&lt;h3 id="81-为什么不直接按固定字数切">&lt;a href="#81-%e4%b8%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e7%9b%b4%e6%8e%a5%e6%8c%89%e5%9b%ba%e5%ae%9a%e5%ad%97%e6%95%b0%e5%88%87" class="header-anchor">&lt;/a>8.1 为什么不直接按固定字数切？
&lt;/h3>&lt;p>固定切分（比如每 500 字一刀）极容易把完整的句子甚至专有名词拦腰截断，导致向量化后的语义是破碎的。比如&amp;quot;中华人民共和国&amp;quot;可能被切成&amp;quot;中华人民共&amp;quot;和&amp;quot;和国&amp;quot;。大模型拿到这种上下文，回答质量会严重下降。&lt;/p>
&lt;h3 id="82-四级递进式切分">&lt;a href="#82-%e5%9b%9b%e7%ba%a7%e9%80%92%e8%bf%9b%e5%bc%8f%e5%88%87%e5%88%86" class="header-anchor">&lt;/a>8.2 四级递进式切分
&lt;/h3>&lt;p>我按语义边界的优先级进行层层降级：&lt;/p>
&lt;p>&lt;strong>第一级 — 段落切分（最高优先级）：&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="n">String&lt;/span>&lt;span class="o">[]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">paragraphs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;nn+&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>段落是最天然的语义单元。优先在段落边界处切割，能最大程度保持语义的内聚性。&lt;/p>
&lt;p>&lt;strong>第二级 — 句子切分（处理超长段落）：&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="n">String&lt;/span>&lt;span class="o">[]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sentences&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">paragraph&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;(?&amp;lt;=[。！？；])|(?&amp;lt;=[.!?;])s+&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>如果单个段落超过块大小限制，就降级到按中英文标点符号切割。&lt;/p>
&lt;p>&lt;strong>第三级 — HanLP 智能分词（处理超长句子）：&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Term&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">termList&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">StandardTokenizer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">segment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sentence&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>如果连单个句子都超长，调用 HanLP 自然语言处理模型按词语边界切分，确保&amp;quot;中华人民共和国&amp;quot;这样的专有名词不会被破坏。&lt;/p>
&lt;p>&lt;strong>第四级 — 字符切分（终极保底）：&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="k">catch&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Exception&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunks&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">splitByCharacters&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sentence&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunkSize&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>如果 HanLP 异常，退化为最原始的逐字符切分。只是保底，正常情况下不会触发。&lt;/p>
&lt;h3 id="83-页码是怎么附加的">&lt;a href="#83-%e9%a1%b5%e7%a0%81%e6%98%af%e6%80%8e%e4%b9%88%e9%99%84%e5%8a%a0%e7%9a%84" class="header-anchor">&lt;/a>8.3 页码是怎么附加的？
&lt;/h3>&lt;p>分块方法本身&lt;strong>完全不关心页码&lt;/strong>，它只负责切字符串。页码是在外层的调用方控制的：&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>PDF 路线&lt;/strong>调用时，传入真实的 &lt;code>pageNumber&lt;/code>（比如 5）&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>非 PDF 路线&lt;/strong>调用时，传入 &lt;code>null&lt;/code>&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>保存方法 &lt;code>saveChildChunks&lt;/code> 收到什么页码就存什么页码，不做任何分类判断：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">private&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">saveChildChunks&lt;/span>&lt;span class="p">(...,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Integer&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pageNumber&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">for&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunks&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">setPageNumber&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pageNumber&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// PDF 传 5 就存 5，非 PDF 传 null 就存 null&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">documentVectorRepository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>这种设计遵循了&lt;strong>高内聚、低耦合&lt;/strong>的原则：分块算法是纯粹的、无状态的文本处理函数。未来如果要支持新格式（比如 PPT 按幻灯片页切分），核心分块算法一行代码都不用改。&lt;/p>
&lt;hr>
&lt;h2 id="九混合检索knn--bm25">&lt;a href="#%e4%b9%9d%e6%b7%b7%e5%90%88%e6%a3%80%e7%b4%a2knn--bm25" class="header-anchor">&lt;/a>九、混合检索（KNN + BM25）
&lt;/h2>&lt;h3 id="91-为什么必须混合">&lt;a href="#91-%e4%b8%ba%e4%bb%80%e4%b9%88%e5%bf%85%e9%a1%bb%e6%b7%b7%e5%90%88" class="header-anchor">&lt;/a>9.1 为什么必须混合？
&lt;/h3>&lt;p>&lt;strong>只用 BM25（关键词检索）的问题：&lt;/strong>&lt;/p>
&lt;p>用户问&amp;quot;怎么请假&amp;quot;，但文档里写的是&amp;quot;员工带薪休假管理办法&amp;quot;。BM25 匹配不到任何关键词，直接返回空。&lt;/p>
&lt;p>&lt;strong>只用 KNN（向量检索）的问题：&lt;/strong>&lt;/p>
&lt;p>用户搜&amp;quot;RFC 7519&amp;quot;，KNN 返回一堆跟&amp;quot;网络协议&amp;quot;语义相关但完全不包含&amp;quot;RFC 7519&amp;quot;这个关键词的文档，答非所问。&lt;/p>
&lt;p>&lt;strong>混合检索让两种能力互补：&lt;/strong> KNN 负责语义召回（把所有可能相关的候选都捞上来），BM25 负责精准排序（确保最终结果包含用户的原始关键词）。&lt;/p>
&lt;h3 id="92-两阶段架构与打分权重">&lt;a href="#92-%e4%b8%a4%e9%98%b6%e6%ae%b5%e6%9e%b6%e6%9e%84%e4%b8%8e%e6%89%93%e5%88%86%e6%9d%83%e9%87%8d" class="header-anchor">&lt;/a>9.2 两阶段架构与打分权重
&lt;/h3>&lt;p>&lt;strong>第一阶段 — KNN 粗召回 + 关键词硬过滤：&lt;/strong>&lt;/p>
&lt;p>先用 KNN 在向量空间中大范围召回 &lt;code>topK × 30&lt;/code> 个语义相近的候选文档，然后用 &lt;code>must&lt;/code> 子句强制要求候选文档必须包含用户查询中的关键词。语义再相似但不含关键词的，直接淘汰。&lt;/p>
&lt;p>&lt;strong>第二阶段 — BM25 精排（Rescore 重打分）：&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">rescore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">windowSize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">recallK&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rq&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rq&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">queryWeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">2d&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// KNN 分数权重：20%&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">rescoreQueryWeight&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">0d&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// BM25 分数权重：100%&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rqq&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rqq&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">match&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;textContent&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">operator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Operator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">And&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>最终得分 = &lt;code>0.2 × KNN 分 + 1.0 × BM25 分&lt;/code>。BM25 主导最终排序，KNN 只作为辅助信号。&lt;/p>
&lt;p>在企业知识库场景中，用户提问通常已经包含了比较明确的关键词，关键词的精确匹配度对结果质量的影响远大于语义相似度。&lt;/p>
&lt;h3 id="93-向量化失败时的自动降级">&lt;a href="#93-%e5%90%91%e9%87%8f%e5%8c%96%e5%a4%b1%e8%b4%a5%e6%97%b6%e7%9a%84%e8%87%aa%e5%8a%a8%e9%99%8d%e7%ba%a7" class="header-anchor">&lt;/a>9.3 向量化失败时的自动降级
&lt;/h3>&lt;p>如果向量模型接口异常（比如网络超时），系统不会直接报错，而是自动降级为纯 BM25 文本搜索：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;span class="lnt">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Float&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">queryVector&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedToVectorList&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">userId&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">queryVector&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">==&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;向量生成失败，仅使用文本匹配进行搜索&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">textOnlySearchWithPermission&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">userDbId&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">userEffectiveTags&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">topK&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>这保证了即使外部依赖出问题，用户的搜索功能也不会完全不可用。&lt;/p>
&lt;hr>
&lt;h2 id="十动态上下文增强策略">&lt;a href="#%e5%8d%81%e5%8a%a8%e6%80%81%e4%b8%8a%e4%b8%8b%e6%96%87%e5%a2%9e%e5%bc%ba%e7%ad%96%e7%95%a5" class="header-anchor">&lt;/a>十、动态上下文增强策略
&lt;/h2>&lt;h3 id="101-什么是长文本限制">&lt;a href="#101-%e4%bb%80%e4%b9%88%e6%98%af%e9%95%bf%e6%96%87%e6%9c%ac%e9%99%90%e5%88%b6" class="header-anchor">&lt;/a>10.1 什么是长文本限制？
&lt;/h3>&lt;p>大语言模型有固定的上下文窗口（比如 8K / 32K / 128K 个 Token）。企业文档动辄几万字甚至几十万字，直接塞给模型要么超限报错，要么注意力分散、回答质量暴跌。&lt;/p>
&lt;h3 id="102-怎么突破">&lt;a href="#102-%e6%80%8e%e4%b9%88%e7%aa%81%e7%a0%b4" class="header-anchor">&lt;/a>10.2 怎么突破？
&lt;/h3>&lt;p>核心思路：&lt;strong>不把整本书塞给模型，只把最相关的几页纸递给它。&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>
&lt;p>文档上传时，就按四级分块算法切成一个个小型文本块，向量化后存入 Elasticsearch&lt;/p>
&lt;/li>
&lt;li>
&lt;p>用户提问时，通过混合检索精准找出最相关的 5~10 个文本块&lt;/p>
&lt;/li>
&lt;li>
&lt;p>将这些块&lt;strong>动态拼接&lt;/strong>成上下文，注入大模型的提示词中&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>&amp;ldquo;动态&amp;quot;体现在：每次用户问的问题不同，检索到的块也不同，上下文是实时组装的。并且系统可以根据模型的窗口大小，动态调整召回的块数量。&lt;/p>
&lt;hr>
&lt;h2 id="四异步处理">&lt;a href="#%e5%9b%9b%e5%bc%82%e6%ad%a5%e5%a4%84%e7%90%86" class="header-anchor">&lt;/a>四、异步处理
&lt;/h2>&lt;p>合并完成后通过 Kafka 异步触发后续流程，客户端无需等待：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="n">kafkaTemplate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;file-process-topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">new&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">FileProcessMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fileId&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">md5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">filePath&lt;/span>&lt;span class="p">));&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 消费者异步执行：&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 1. Apache Tika 解析文档内容&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 2. 文本分段&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 3. 通义千问 Embedding 向量化&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">// 4. 写入 Elasticsearch 索引&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="写在最后">&lt;a href="#%e5%86%99%e5%9c%a8%e6%9c%80%e5%90%8e" class="header-anchor">&lt;/a>写在最后
&lt;/h2>&lt;p>回顾整个大文件异步流水线，最让我有成就感的，不是某一个单独的技术点，而是&lt;strong>整条链路的协同设计&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>
&lt;p>前端切片 + 后端 MinIO 零拷贝合并 → &lt;strong>解决传输瓶颈&lt;/strong>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Redis BitMap + MySQL + MinIO 三重保障 → &lt;strong>解决数据可靠性&lt;/strong>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Kafka 事务性发送 + 重试 + 死信 → &lt;strong>解决异步可靠性&lt;/strong>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>内存熔断 + 双轨解析 + 流式缓冲 → &lt;strong>解决稳定性&lt;/strong>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>四级语义分块 + 混合检索 → &lt;strong>解决 RAG 质量&lt;/strong>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>预估消耗 + 预扣费 → &lt;strong>解决计费准确性&lt;/strong>&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>每个环节都不是孤立的炫技，而是为了整条链路的最终目标——&lt;strong>让用户上传任意格式的大文件后，能通过自然语言高质量地找到他想要的答案&lt;/strong>。&lt;/p>
&lt;h2 id="方案总结">&lt;a href="#%e6%96%b9%e6%a1%88%e6%80%bb%e7%bb%93" class="header-anchor">&lt;/a>方案总结
&lt;/h2>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>技术点&lt;/th>
 &lt;th>解决的问题&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>文件 MD5 + Redis&lt;/td>
 &lt;td>秒传，避免重复上传&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Redis BitMap&lt;/td>
 &lt;td>断点续传，记录分片状态&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>FileChannel.transferTo&lt;/td>
 &lt;td>零拷贝合并，消除内存瓶颈&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Kafka&lt;/td>
 &lt;td>异步解耦上传与处理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

 &lt;blockquote>
 &lt;p>大文件处理的核心思路：&lt;strong>分而治之 + 异步解耦&lt;/strong>。把大问题拆成小问题（分片），把慢操作放到后台（Kafka 异步）。&lt;/p>
 &lt;/blockquote>
&lt;h2 id="整体流程">&lt;a href="#%e6%95%b4%e4%bd%93%e6%b5%81%e7%a8%8b" class="header-anchor">&lt;/a>整体流程
&lt;/h2>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">客户端 服务端 存储
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── 1. 计算文件 MD5 ────────&amp;gt;│ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── 2. Redis 查是否秒传 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │&amp;lt;── 3. 返回已上传分片列表 ──│ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── 4. 上传缺失分片 ────────&amp;gt;│── 5. 存入 MinIO ──────&amp;gt;│
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── 6. BitMap 标记分片 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── 7. 全部分片完毕 ────────&amp;gt;│ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── 8. 零拷贝合并 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── 9. Kafka 异步处理 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │&amp;lt;── 10. 返回成功 ──────────│ │
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div></description></item><item><title>Java 集合框架深度笔记：HashMap、ConcurrentHashMap 与 ArrayList</title><link>https://blog.canggo.com/p/java-collections/</link><pubDate>Wed, 08 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog.canggo.com/p/java-collections/</guid><description>&lt;blockquote>
 &lt;p>这是我花了几个小时死磕 Java 集合底层源码后的总结。不是背的八股文，是我自己一行行推演出来的理解。以后面试前翻一遍这个就够了。&lt;/p>
 &lt;/blockquote>
&lt;hr>
&lt;h2 id="一hashmap面试出现率最高的集合没有之一">&lt;a href="#%e4%b8%80hashmap%e9%9d%a2%e8%af%95%e5%87%ba%e7%8e%b0%e7%8e%87%e6%9c%80%e9%ab%98%e7%9a%84%e9%9b%86%e5%90%88%e6%b2%a1%e6%9c%89%e4%b9%8b%e4%b8%80" class="header-anchor">&lt;/a>一、HashMap（面试出现率最高的集合，没有之一）
&lt;/h2>&lt;h3 id="11-整体结构">&lt;a href="#11-%e6%95%b4%e4%bd%93%e7%bb%93%e6%9e%84" class="header-anchor">&lt;/a>1.1 整体结构
&lt;/h3>&lt;p>HashMap 的底层说白了就是一个&lt;strong>数组&lt;/strong>，数组里每个格子叫&amp;quot;桶&amp;quot;。往里面放数据的时候，先算出 key 的 hash 值，然后用这个 hash 值去决定放在哪个桶里。如果两个 key 算出来的桶位置一样（哈希冲突），就在那个桶后面挂一条&lt;strong>链表&lt;/strong>。链表太长了（达到 8 个节点），再升级成&lt;strong>红黑树&lt;/strong>。&lt;/p>
&lt;p>所以本质就是：&lt;strong>数组 + 链表 + 红黑树&lt;/strong>。&lt;/p>
&lt;h3 id="12-hash-怎么算的为什么要搞个扰动函数">&lt;a href="#12-hash-%e6%80%8e%e4%b9%88%e7%ae%97%e7%9a%84%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e6%90%9e%e4%b8%aa%e6%89%b0%e5%8a%a8%e5%87%bd%e6%95%b0" class="header-anchor">&lt;/a>1.2 hash 怎么算的？为什么要搞个&amp;quot;扰动函数&amp;quot;？
&lt;/h3>&lt;p>HashMap 不是直接拿 key 的 hashCode 去用的。它多做了一步：把 hashCode 的高 16 位和低 16 位做了一次异或运算（&lt;code>hash ^ (hash &amp;gt;&amp;gt;&amp;gt; 16)&lt;/code>）。&lt;/p>
&lt;p>为什么要多此一举？因为算桶位置的时候用的是 &lt;code>hash &amp;amp; (n-1)&lt;/code>，实际上只有 hash 值的最后几位在起作用，高位的特征全被浪费了。扰动函数就是把高位的信息&amp;quot;混&amp;quot;到低位里去，让散列更均匀，减少冲突。&lt;/p>
&lt;p>至于为什么用异或而不是用与（&amp;amp;）或者或（|）？因为异或是唯一一个输出 0 和 1 概率各占 50% 的运算，最大程度保留了随机性。与运算会偏向 0，或运算会偏向 1，都不行。&lt;/p>
&lt;h3 id="13-为什么容量必须是-2-的-n-次方">&lt;a href="#13-%e4%b8%ba%e4%bb%80%e4%b9%88%e5%ae%b9%e9%87%8f%e5%bf%85%e9%a1%bb%e6%98%af-2-%e7%9a%84-n-%e6%ac%a1%e6%96%b9" class="header-anchor">&lt;/a>1.3 为什么容量必须是 2 的 n 次方？
&lt;/h3>&lt;p>这个是 HashMap 最精妙的设计之一。&lt;/p>
&lt;p>算桶位置的公式是 &lt;code>hash &amp;amp; (n-1)&lt;/code>。当 n 是 2 的幂次方时，&lt;code>n-1&lt;/code> 的二进制全是 1（比如 16-1=15，二进制是 1111）。这样做 &amp;amp; 运算的结果完全取决于 hash 值本身，每个桶都有可能被分配到。&lt;/p>
&lt;p>但如果 n 不是 2 的幂，比如 n=10，那 &lt;code>n-1=9&lt;/code>，二进制是 1001，中间两位永远是 0。不管你 hash 值是啥，算出来的桶位置中间两位永远是 0，大量桶位置永远用不上，哈希冲突会极其严重。&lt;/p>
&lt;p>而且位运算（&amp;amp;）比取模运算（%）快几十倍。所以这个设计既保证了散列均匀，又保证了极致的性能。&lt;/p>
&lt;p>如果你 &lt;code>new HashMap&amp;lt;&amp;gt;(10)&lt;/code>，底层会通过 &lt;code>tableSizeFor()&lt;/code> 方法自动向上取到最近的 2 的幂，也就是 16。它用了一串连续的无符号右移和按位或操作来实现，全程位运算，极快。&lt;/p>
&lt;h3 id="14-put-的完整流程">&lt;a href="#14-put-%e7%9a%84%e5%ae%8c%e6%95%b4%e6%b5%81%e7%a8%8b" class="header-anchor">&lt;/a>1.4 put 的完整流程
&lt;/h3>&lt;p>这个是面试被问到最多的，我自己推演了一遍源码：&lt;/p>
&lt;ol>
&lt;li>先看 table 数组是不是空的。第一次 put 的时候 table 还是 null（懒加载），会先调 resize() 初始化一个默认长度 16 的数组。&lt;/li>
&lt;li>用 &lt;code>(n-1) &amp;amp; hash&lt;/code> 算出桶下标。&lt;/li>
&lt;li>如果那个桶是空的，直接创建新节点放进去，完事。&lt;/li>
&lt;li>如果桶不为空（哈希冲突了）：
&lt;ul>
&lt;li>先看桶里第一个节点的 key 是不是跟要放的 key 相同（先比 hash，再比 equals）。相同就准备覆盖旧值。&lt;/li>
&lt;li>如果第一个不是，看这个桶是不是已经变成红黑树了。是的话走红黑树的插入逻辑。&lt;/li>
&lt;li>如果还是链表，就一个个遍历下去。遍历过程中找到相同的 key 就覆盖；遍历到尾部还没找到，就在&lt;strong>尾部追加新节点&lt;/strong>（JDK 8 是尾插法，JDK 7 是头插法）。&lt;/li>
&lt;li>插入后检查链表长度有没有到 8。到了的话调 treeifyBin()。&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>treeifyBin() 里面还会再判断一次：数组长度有没有到 64。如果数组太短（不到 64），说明冲突是因为容量不够导致的，优先扩容而不是转树。&lt;strong>只有链表长度 ≥ 8 且数组长度 ≥ 64，才会真正转红黑树。&lt;/strong>&lt;/li>
&lt;li>最后检查 size 有没有超过阈值（容量 × 0.75），超过了就扩容。&lt;/li>
&lt;/ol>
&lt;p>为什么先比 hash 再比 equals？因为 hash 是 int 比较，一条 CPU 指令就搞定了。equals 可能要比较好多个字段，很慢。先用便宜的操作排除掉大部分不匹配的，再用昂贵的操作做精确确认。&lt;/p>
&lt;h3 id="15-get-的流程">&lt;a href="#15-get-%e7%9a%84%e6%b5%81%e7%a8%8b" class="header-anchor">&lt;/a>1.5 get 的流程
&lt;/h3>&lt;p>get 比 put 简单多了：&lt;/p>
&lt;ol>
&lt;li>算 hash，定位桶。&lt;/li>
&lt;li>检查桶里第一个节点是不是目标（大多数情况一次命中）。&lt;/li>
&lt;li>不是的话，看是红黑树还是链表，分别用对应的方式遍历查找。&lt;/li>
&lt;li>找不到就返回 null。&lt;/li>
&lt;/ol>
&lt;h3 id="16-扩容机制jdk-8-的平移魔法">&lt;a href="#16-%e6%89%a9%e5%ae%b9%e6%9c%ba%e5%88%b6jdk-8-%e7%9a%84%e5%b9%b3%e7%a7%bb%e9%ad%94%e6%b3%95" class="header-anchor">&lt;/a>1.6 扩容机制（JDK 8 的平移魔法）
&lt;/h3>&lt;p>触发扩容的条件：元素个数 &amp;gt; 容量 × 0.75。&lt;/p>
&lt;p>扩容时数组容量翻倍（左移 1 位）。重点是数据怎么迁移。&lt;/p>
&lt;p>JDK 7 的做法是对每个元素重新算一遍 &lt;code>hash &amp;amp; (newCap-1)&lt;/code>，很慢。JDK 8 巧妙地利用了一个数学规律：扩容后 &lt;code>n-1&lt;/code> 的掩码只是在高位多了一个 1。所以只需要看每个元素的 hash 值在那个新增的高位上是 0 还是 1。&lt;/p>
&lt;p>具体做法是用 &lt;code>hash &amp;amp; oldCap&lt;/code> 来判断：&lt;/p>
&lt;ul>
&lt;li>结果为 0：留在原来的桶位置。&lt;/li>
&lt;li>结果不为 0：搬到 &lt;strong>原位置 + 旧容量&lt;/strong> 的新位置。&lt;/li>
&lt;/ul>
&lt;p>这样一条链表会被拆成高低两条链表，分别放到新数组的两个位置上。全程不需要重新计算 hash，一次位运算搞定，极致优雅。&lt;/p>
&lt;p>而且 JDK 8 扩容拆链表用的是尾插法，保持了原来的顺序。JDK 7 用的头插法会把顺序反过来，多线程下会形成环形链表导致死循环（经典面试题）。&lt;/p>
&lt;h3 id="17-链表转红黑树的那些细节">&lt;a href="#17-%e9%93%be%e8%a1%a8%e8%bd%ac%e7%ba%a2%e9%bb%91%e6%a0%91%e7%9a%84%e9%82%a3%e4%ba%9b%e7%bb%86%e8%8a%82" class="header-anchor">&lt;/a>1.7 链表转红黑树的那些细节
&lt;/h3>&lt;p>&lt;strong>为什么阈值是 8？&lt;/strong> JDK 源码注释里直接引用了泊松分布的数据。在负载因子 0.75 的情况下，一个桶里挂到 8 个节点的概率是千万分之六，几乎不可能。所以树化只是极端情况的安全兜底。&lt;/p>
&lt;p>&lt;strong>为什么退化阈值是 6 而不是 8？&lt;/strong> 为了防止反复横跳。如果转换和退化都用 8，当链表长度在 7 和 8 之间来回波动时，就会不停地转树、退回链表、转树、退回链表，白白浪费性能。中间留出 6 到 8 的缓冲区。&lt;/p>
&lt;p>&lt;strong>为什么用红黑树不用 AVL 树？&lt;/strong> 红黑树是弱平衡的，插入删除时旋转次数少（最多 3 次），综合读写性能更好。AVL 树严格平衡，查找快一丢丢，但插入删除时旋转次数多。HashMap 的场景是读写都频繁，红黑树更合适。&lt;/p>
&lt;h3 id="18-负载因子为什么是-075">&lt;a href="#18-%e8%b4%9f%e8%bd%bd%e5%9b%a0%e5%ad%90%e4%b8%ba%e4%bb%80%e4%b9%88%e6%98%af-075" class="header-anchor">&lt;/a>1.8 负载因子为什么是 0.75？
&lt;/h3>&lt;p>0.75 是空间利用率和哈希冲突概率之间的最佳平衡点。&lt;/p>
&lt;p>太大（比如 1.0）：数组装满才扩容，冲突严重，链表很长，查询变慢。
太小（比如 0.25）：浪费大量空间。
0.75 刚好：75% 使用率，根据泊松分布计算，每个桶的平均元素数约 0.5，链表超过 8 的概率只有千万分之六。&lt;/p>
&lt;h3 id="19-存-20-个元素扩容几次">&lt;a href="#19-%e5%ad%98-20-%e4%b8%aa%e5%85%83%e7%b4%a0%e6%89%a9%e5%ae%b9%e5%87%a0%e6%ac%a1" class="header-anchor">&lt;/a>1.9 存 20 个元素扩容几次？
&lt;/h3>&lt;p>初始容量 16，阈值 12。存到第 13 个时触发扩容，容量变 32，阈值变 24。之后存到 20 都不会再扩容。答案是 &lt;strong>1 次&lt;/strong>。&lt;/p>
&lt;p>如果提前知道要存 1000 个元素，应该 &lt;code>new HashMap&amp;lt;&amp;gt;(1334)&lt;/code>，底层会向上取到 2048，阈值是 1536，永远不会扩容。&lt;/p>
&lt;h3 id="110-为什么推荐用-string-做-key">&lt;a href="#110-%e4%b8%ba%e4%bb%80%e4%b9%88%e6%8e%a8%e8%8d%90%e7%94%a8-string-%e5%81%9a-key" class="header-anchor">&lt;/a>1.10 为什么推荐用 String 做 Key？
&lt;/h3>&lt;p>三个原因：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>不可变&lt;/strong>：hashCode 算一次就缓存起来了，后续调用直接用缓存，不用重新算。&lt;/li>
&lt;li>&lt;strong>天然重写了 equals 和 hashCode&lt;/strong>：直接能用，不用操心。&lt;/li>
&lt;li>&lt;strong>不会被修改&lt;/strong>：不存在 hash 值变了但数据还在旧桶里的内存泄漏问题。&lt;/li>
&lt;/ol>
&lt;p>如果用可变对象做 Key，put 之后又改了那个对象的属性，它的 hashCode 就变了。再去 get 的时候，根据新 hashCode 去别的桶找，当然找不到。而旧数据还死死卡在原来的桶里，GC 也回收不了（因为 HashMap 的 table 数组还持有它的强引用），这就是经典的内存泄漏。&lt;/p>
&lt;h3 id="111-hashmap-的线程安全问题">&lt;a href="#111-hashmap-%e7%9a%84%e7%ba%bf%e7%a8%8b%e5%ae%89%e5%85%a8%e9%97%ae%e9%a2%98" class="header-anchor">&lt;/a>1.11 HashMap 的线程安全问题
&lt;/h3>&lt;p>&lt;strong>HashMap 绝对不是线程安全的。&lt;/strong>&lt;/p>
&lt;p>JDK 7 的致命问题：多线程并发扩容时，头插法会导致链表形成环，后续 get 操作会陷入死循环，CPU 直接飙到 100%。&lt;/p>
&lt;p>JDK 8 改成了尾插法，解决了死循环。但依然有两个致命问题：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>数据覆盖&lt;/strong>：两个线程同时往一个空桶里 put，都判断桶是空的，后放的直接把先放的覆盖了。&lt;/li>
&lt;li>&lt;strong>size 不准&lt;/strong>：&lt;code>size++&lt;/code> 不是原子操作，两个线程同时加，可能只加了 1。&lt;/li>
&lt;/ol>
&lt;p>多线程环境下必须用 ConcurrentHashMap。&lt;/p>
&lt;h3 id="112-hashmap-的-key-可以为-null-吗">&lt;a href="#112-hashmap-%e7%9a%84-key-%e5%8f%af%e4%bb%a5%e4%b8%ba-null-%e5%90%97" class="header-anchor">&lt;/a>1.12 HashMap 的 key 可以为 null 吗？
&lt;/h3>&lt;p>可以，而且只能有一个 null key。源码里对 null 的处理是直接返回 hash 值 0，所以 null key 永远放在 table[0] 这个桶里。&lt;/p>
&lt;p>但是 ConcurrentHashMap 和 Hashtable 的 key 和 value 都不能为 null。&lt;/p>
&lt;hr>
&lt;h2 id="二concurrenthashmap并发面试必考">&lt;a href="#%e4%ba%8cconcurrenthashmap%e5%b9%b6%e5%8f%91%e9%9d%a2%e8%af%95%e5%bf%85%e8%80%83" class="header-anchor">&lt;/a>二、ConcurrentHashMap（并发面试必考）
&lt;/h2>&lt;h3 id="21-jdk-7-的分段锁">&lt;a href="#21-jdk-7-%e7%9a%84%e5%88%86%e6%ae%b5%e9%94%81" class="header-anchor">&lt;/a>2.1 JDK 7 的分段锁
&lt;/h3>&lt;p>JDK 7 的思路是把一个大 HashMap 切成 16 个小 HashMap，每个小的叫 Segment。Segment 直接继承了 ReentrantLock，所以它天生就是可重入锁。&lt;/p>
&lt;p>写数据的时候只锁对应的那个 Segment，其他 15 个不受影响。最多允许 16 个线程同时写。&lt;/p>
&lt;p>问题在于：并发度被固定死了（默认 16），而且每个 Segment 对象本身很重，浪费内存。&lt;/p>
&lt;h3 id="22-jdk-8-的-cas--synchronized">&lt;a href="#22-jdk-8-%e7%9a%84-cas--synchronized" class="header-anchor">&lt;/a>2.2 JDK 8 的 CAS + synchronized
&lt;/h3>&lt;p>JDK 8 把分段锁整个推翻了。数据结构回归为和 HashMap 一样的数组+链表+红黑树。但锁的粒度细化到了&lt;strong>每一个桶的头节点&lt;/strong>。&lt;/p>
&lt;p>put 的时候分两种情况：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>桶是空的&lt;/strong>：用 CAS 直接塞进去。因为是一步操作，CAS 硬件指令就能保证原子性，全程无锁，极快。&lt;/li>
&lt;li>&lt;strong>桶不为空（有冲突）&lt;/strong>：用 synchronized 锁住这个桶的头节点，在锁里面安心地遍历链表或红黑树。&lt;/li>
&lt;/ul>
&lt;p>如果 CAS 失败了（说明有人抢先把桶占了），不会抛异常，而是进入下一次 for 循环重试。这次发现桶不为空了，就会自动切换到 synchronized 分支。&lt;/p>
&lt;p>&lt;strong>为什么 JDK 8 又把 synchronized 请回来了？&lt;/strong> 因为从 JDK 6 开始，JVM 对 synchronized 做了史诗级优化（偏向锁、轻量级锁、锁升级）。在竞争不激烈时性能和 ReentrantLock 不相上下，而且不用创建锁对象，省内存。&lt;/p>
&lt;h3 id="23-读操作全程无锁">&lt;a href="#23-%e8%af%bb%e6%93%8d%e4%bd%9c%e5%85%a8%e7%a8%8b%e6%97%a0%e9%94%81" class="header-anchor">&lt;/a>2.3 读操作全程无锁
&lt;/h3>&lt;p>get 方法不需要加任何锁。这是因为 Node 节点的 val 和 next 都被 volatile 修饰了。&lt;/p>
&lt;p>volatile 保证了内存可见性：一个线程改了值，会立刻强制刷新到主内存，其他线程的 CPU 缓存里的旧值自动失效。所以读线程永远能看到最新的数据。&lt;/p>
&lt;p>就算某个桶正在被 synchronized 锁住做写操作，读线程依然可以毫无阻塞地读，因为 volatile 保证了可见性，不需要锁来保护。&lt;/p>
&lt;h3 id="24-size-的极致统计longadder-思想">&lt;a href="#24-size-%e7%9a%84%e6%9e%81%e8%87%b4%e7%bb%9f%e8%ae%a1longadder-%e6%80%9d%e6%83%b3" class="header-anchor">&lt;/a>2.4 size 的极致统计（LongAdder 思想）
&lt;/h3>&lt;p>如果用一个 AtomicInteger 来统计 size，高并发下所有线程都去 CAS 争抢同一个变量，大量线程 CAS 失败后疯狂自旋，CPU 直接被榨干。&lt;/p>
&lt;p>ConcurrentHashMap 学了 LongAdder 的思路：化整为零。&lt;/p>
&lt;p>内部有一个 baseCount 和一个 CounterCell 数组。平时没竞争就直接 CAS 加 baseCount。一旦 CAS 失败（有竞争了），线程就用自己的随机 hash 值，分散到 CounterCell 数组的不同格子里去加。&lt;/p>
&lt;p>统计 size 的时候，把 baseCount 和所有格子的值加起来就行了。&lt;/p>
&lt;p>但这个 size 是弱一致性的。遍历数组求和的过程中没有加锁，其他线程可能还在往里面加数据。所以拿到的值可能不是绝对精准的。在高并发场景下，绝对不能用 &lt;code>map.size()&lt;/code> 来做库存拦截或者秒杀判断。&lt;/p>
&lt;p>CounterCell 上面还加了个 &lt;code>@sun.misc.Contended&lt;/code> 注解，是为了解决 CPU 缓存的&lt;strong>伪共享&lt;/strong>问题。CPU 缓存是按缓存行（64字节）读取的，如果多个 CounterCell 挤在同一个缓存行里，一个被改了整个缓存行都失效，其他格子也得跟着重新从主内存读。这个注解会在对象前后填充空白字节，把不同的 Cell 强行挤到不同的缓存行里。&lt;/p>
&lt;h3 id="25-为什么不允许-null">&lt;a href="#25-%e4%b8%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e5%85%81%e8%ae%b8-null" class="header-anchor">&lt;/a>2.5 为什么不允许 null？
&lt;/h3>&lt;p>为了避免二义性。在单线程的 HashMap 里，get 返回 null 后可以用 containsKey 去证伪。但在 ConcurrentHashMap 里，你 get 到 null 还没来得及 containsKey，别的线程可能已经把数据改了。你永远无法证明这个 null 到底是&amp;quot;值就是 null&amp;quot;还是&amp;quot;key 不存在&amp;quot;。&lt;/p>
&lt;h3 id="26-复合操作的陷阱">&lt;a href="#26-%e5%a4%8d%e5%90%88%e6%93%8d%e4%bd%9c%e7%9a%84%e9%99%b7%e9%98%b1" class="header-anchor">&lt;/a>2.6 复合操作的陷阱
&lt;/h3>&lt;p>虽然 ConcurrentHashMap 内部是线程安全的，但 get 之后判断 if null 再 put，这三步合在一起不是原子的。高并发下依然会丢数据。&lt;/p>
&lt;p>普通场景可以用 merge 或 putIfAbsent。但如果是热点 Key 的高并发累加（比如统计播放量），用 merge 所有线程都在一个桶的头节点上排队，性能很差。终极方案是用 &lt;code>ConcurrentHashMap&amp;lt;String, LongAdder&amp;gt;&lt;/code>，把累加操作下沉到 LongAdder 内部的分散格子里。&lt;/p>
&lt;hr>
&lt;h2 id="三volatilejuc-并发编程的灵魂基石">&lt;a href="#%e4%b8%89volatilejuc-%e5%b9%b6%e5%8f%91%e7%bc%96%e7%a8%8b%e7%9a%84%e7%81%b5%e9%ad%82%e5%9f%ba%e7%9f%b3" class="header-anchor">&lt;/a>三、volatile（JUC 并发编程的灵魂基石）
&lt;/h2>&lt;p>volatile 有两个超能力和一个致命弱点。&lt;/p>
&lt;h3 id="31-超能力一内存可见性">&lt;a href="#31-%e8%b6%85%e8%83%bd%e5%8a%9b%e4%b8%80%e5%86%85%e5%ad%98%e5%8f%af%e8%a7%81%e6%80%a7" class="header-anchor">&lt;/a>3.1 超能力一：内存可见性
&lt;/h3>&lt;p>一个线程改了 volatile 变量，JVM 会强制把新值刷到主内存，并且让其他线程 CPU 缓存里的旧值失效。其他线程读的时候，跳过本地缓存直接从主内存拿最新值。&lt;/p>
&lt;p>经典案例：一个 while(isRunning) 循环，如果 isRunning 没加 volatile，另一个线程把它改成 false，循环线程可能永远看不到，因为它一直读的是自己 CPU 缓存里的旧值 true，死循环。&lt;/p>
&lt;h3 id="32-超能力二禁止指令重排">&lt;a href="#32-%e8%b6%85%e8%83%bd%e5%8a%9b%e4%ba%8c%e7%a6%81%e6%ad%a2%e6%8c%87%e4%bb%a4%e9%87%8d%e6%8e%92" class="header-anchor">&lt;/a>3.2 超能力二：禁止指令重排
&lt;/h3>&lt;p>CPU 为了优化性能会重排指令顺序。volatile 会在变量的读写前后插入内存屏障，禁止跨过屏障的指令重排。&lt;/p>
&lt;p>经典案例就是双重检查锁（DCL）单例模式。&lt;code>new Singleton()&lt;/code> 在底层分三步：分配内存、初始化对象、赋值引用。CPU 可能重排成分配内存、赋值引用、初始化对象。这样别的线程看到引用不是 null 就直接用了，但对象还没初始化完，直接空指针。加了 volatile 就能强制这三步严格按顺序执行。&lt;/p>
&lt;h3 id="33-致命弱点不保证原子性">&lt;a href="#33-%e8%87%b4%e5%91%bd%e5%bc%b1%e7%82%b9%e4%b8%8d%e4%bf%9d%e8%af%81%e5%8e%9f%e5%ad%90%e6%80%a7" class="header-anchor">&lt;/a>3.3 致命弱点：不保证原子性
&lt;/h3>&lt;p>volatile 只能保证读到最新值，但 &lt;code>count++&lt;/code> 这种操作（读、加、写三步），两个线程可能同时读到同一个值，各自加 1 后写回去，结果只加了 1。&lt;/p>
&lt;p>解决办法：用 AtomicInteger（底层是 CAS），或者加 synchronized。&lt;/p>
&lt;hr>
&lt;h2 id="四arraylist">&lt;a href="#%e5%9b%9barraylist" class="header-anchor">&lt;/a>四、ArrayList
&lt;/h2>&lt;h3 id="41-底层结构">&lt;a href="#41-%e5%ba%95%e5%b1%82%e7%bb%93%e6%9e%84" class="header-anchor">&lt;/a>4.1 底层结构
&lt;/h3>&lt;p>就是一个 Object 数组（elementData），外加一个 size 记录实际元素个数。&lt;/p>
&lt;p>注意 size 和 capacity 的区别：size 是实际装了多少个，capacity 是数组的物理长度。&lt;/p>
&lt;h3 id="42-扩容机制">&lt;a href="#42-%e6%89%a9%e5%ae%b9%e6%9c%ba%e5%88%b6" class="header-anchor">&lt;/a>4.2 扩容机制
&lt;/h3>&lt;p>默认初始容量是 10，但用了&lt;strong>懒加载&lt;/strong>。new 的时候底层数组是空的，第一次 add 时才扩容到 10。&lt;/p>
&lt;p>每次扩容新容量 = 旧容量 × 1.5（用位运算 &lt;code>oldCapacity + (oldCapacity &amp;gt;&amp;gt; 1)&lt;/code> 实现）。&lt;/p>
&lt;p>扩容后通过 Arrays.copyOf 创建新数组，底层调的是 System.arraycopy 这个 native 方法，C++ 级别的内存块拷贝，极快。&lt;/p>
&lt;p>&lt;strong>为什么是 1.5 倍不是 2 倍？&lt;/strong> HashMap 必须 2 倍是因为要保证 2 的幂做位运算。ArrayList 没这个限制，1.5 倍更省内存。&lt;/p>
&lt;p>&lt;strong>边界防御&lt;/strong>：如果 oldCapacity 是 1，&lt;code>1 + (1&amp;gt;&amp;gt;1) = 1&lt;/code>，等于没扩。但源码里有双重保险：算完 1.5 倍后还会跟最低需求值比较，取大的那个。所以不会出问题。&lt;/p>
&lt;p>如果提前知道要存多少数据，直接指定初始容量，比如 &lt;code>new ArrayList&amp;lt;&amp;gt;(1000)&lt;/code>，避免反复扩容的性能损耗。&lt;/p>
&lt;h3 id="43-为什么不是线程安全的">&lt;a href="#43-%e4%b8%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e6%98%af%e7%ba%bf%e7%a8%8b%e5%ae%89%e5%85%a8%e7%9a%84" class="header-anchor">&lt;/a>4.3 为什么不是线程安全的？
&lt;/h3>&lt;p>两个致命场景：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>size++ 不是原子操作&lt;/strong>：两个线程同时读到 size=5，都往下标 5 放数据，后放的覆盖先放的，数据丢失。&lt;/li>
&lt;li>&lt;strong>扩容判断不安全&lt;/strong>：两个线程同时判断不需要扩容，然后同时 add，数组空间不够，ArrayIndexOutOfBoundsException。&lt;/li>
&lt;/ol>
&lt;h3 id="44-序列化的骚操作">&lt;a href="#44-%e5%ba%8f%e5%88%97%e5%8c%96%e7%9a%84%e9%aa%9a%e6%93%8d%e4%bd%9c" class="header-anchor">&lt;/a>4.4 序列化的骚操作
&lt;/h3>&lt;p>底层数组 elementData 被 transient 修饰，意思是默认不序列化。但 ArrayList 依然能被序列化，因为它自己写了 writeObject 和 readObject 方法。&lt;/p>
&lt;p>为什么要这么做？因为数组的 capacity 通常远大于 size。如果直接序列化整个数组，后面一堆 null 全跟着走了，白白浪费带宽和空间。自定义序列化只写前 size 个有效元素，反序列化时也只创建刚好够用的数组。&lt;/p>
&lt;hr>
&lt;h2 id="五linkedlist">&lt;a href="#%e4%ba%94linkedlist" class="header-anchor">&lt;/a>五、LinkedList
&lt;/h2>&lt;h3 id="51-底层结构">&lt;a href="#51-%e5%ba%95%e5%b1%82%e7%bb%93%e6%9e%84" class="header-anchor">&lt;/a>5.1 底层结构
&lt;/h3>&lt;p>标准的双向链表，每个 Node 包含 item（数据）、prev（前驱指针）、next（后继指针）。整个 LinkedList 维护了 first 和 last 两个指针。&lt;/p>
&lt;p>它还实现了 Deque 接口，所以可以当队列、栈、双端队列用。&lt;/p>
&lt;h3 id="52-中间插入真的比-arraylist-快吗">&lt;a href="#52-%e4%b8%ad%e9%97%b4%e6%8f%92%e5%85%a5%e7%9c%9f%e7%9a%84%e6%af%94-arraylist-%e5%bf%ab%e5%90%97" class="header-anchor">&lt;/a>5.2 中间插入真的比 ArrayList 快吗？
&lt;/h3>&lt;p>&lt;strong>不一定，大部分情况下并不快。&lt;/strong> 这是一个经典的认知误区。&lt;/p>
&lt;p>LinkedList 修改指针确实是 O(1)，但在调 add(index, e) 的时候，得先调 node(index) 找到那个位置。node 方法虽然做了个小优化（index 在前半段从头找，后半段从尾找），但本质还是逐个节点遍历，O(n)。&lt;/p>
&lt;p>所以中间插入两者都是 O(n)。而且 ArrayList 的数组搬运用的是 System.arraycopy（native 方法，整块内存拷贝），实测往往比 LinkedList 的链表遍历还快。&lt;/p>
&lt;p>LinkedList 唯一有优势的场景是&lt;strong>纯粹的头部或尾部操作&lt;/strong>（addFirst/addLast/removeFirst/removeLast），因为有 first 和 last 指针可以 O(1) 直达。&lt;/p>
&lt;hr>
&lt;h2 id="六arraylist-vs-linkedlist-的硬件级性能差异">&lt;a href="#%e5%85%adarraylist-vs-linkedlist-%e7%9a%84%e7%a1%ac%e4%bb%b6%e7%ba%a7%e6%80%a7%e8%83%bd%e5%b7%ae%e5%bc%82" class="header-anchor">&lt;/a>六、ArrayList vs LinkedList 的硬件级性能差异
&lt;/h2>&lt;h3 id="61-遍历性能arraylist-碾压">&lt;a href="#61-%e9%81%8d%e5%8e%86%e6%80%a7%e8%83%bdarraylist-%e7%a2%be%e5%8e%8b" class="header-anchor">&lt;/a>6.1 遍历性能：ArrayList 碾压
&lt;/h3>&lt;p>理论上遍历都是 O(n)，但实测 ArrayList 比 LinkedList 快 10 倍以上。&lt;/p>
&lt;p>原因是&lt;strong>CPU 缓存行（Cache Line）和空间局部性（Spatial Locality）&lt;/strong>。&lt;/p>
&lt;p>CPU 每次从主内存读数据是按缓存行（64 字节）整块加载的。ArrayList 底层是连续数组，读第 1 个元素时顺便就把后面十几个元素一起拉进了高速缓存。后续访问直接缓存命中（Cache Hit），纳秒级。&lt;/p>
&lt;p>LinkedList 的节点在堆内存中随机分布，物理地址天南地北。每访问一个节点，CPU 缓存里的数据大概率用不上（Cache Miss），只能重新去主内存捞。主内存的延迟是 L1 缓存的 100 倍以上。&lt;/p>
&lt;p>&lt;strong>而且绝对不能用普通 for 循环遍历 LinkedList！&lt;/strong> &lt;code>list.get(i)&lt;/code> 每次都从头重新找，O(n²) 复杂度。必须用 Iterator 或 foreach。&lt;/p>
&lt;h3 id="62-内存占用linkedlist-浪费严重">&lt;a href="#62-%e5%86%85%e5%ad%98%e5%8d%a0%e7%94%a8linkedlist-%e6%b5%aa%e8%b4%b9%e4%b8%a5%e9%87%8d" class="header-anchor">&lt;/a>6.2 内存占用：LinkedList 浪费严重
&lt;/h3>&lt;p>在 64 位 JVM（开启指针压缩）下，一个 LinkedList.Node 占 24 字节（对象头 12 + 三个引用 12）。&lt;/p>
&lt;p>存 100 万个 Integer：&lt;/p>
&lt;ul>
&lt;li>ArrayList：数组引用约 4MB。&lt;/li>
&lt;li>LinkedList：Node 对象约 24MB。&lt;/li>
&lt;/ul>
&lt;p>光维护数据结构的开销，LinkedList 就是 ArrayList 的 6 倍。如果对内存极度敏感，直接用 int[] 基本类型数组，100 万个 int 只要 4MB。&lt;/p>
&lt;h3 id="63-队列选型arraydeque-碾压-linkedlist">&lt;a href="#63-%e9%98%9f%e5%88%97%e9%80%89%e5%9e%8barraydeque-%e7%a2%be%e5%8e%8b-linkedlist" class="header-anchor">&lt;/a>6.3 队列选型：ArrayDeque 碾压 LinkedList
&lt;/h3>&lt;p>如果要用队列或栈，别用 LinkedList，用 ArrayDeque。&lt;/p>
&lt;p>ArrayDeque 底层是首尾相接的环形数组，头尾操作通过位运算移动指针，绝对 O(1)。不用创建 Node 对象，没有 GC 压力。内存连续，CPU 缓存友好。&lt;/p>
&lt;p>ArrayDeque 不能存 null，因为底层用 &lt;code>elements[head] == null&lt;/code> 来判断队列是否为空。允许 null 的话就有二义性了。&lt;/p>
&lt;hr>
&lt;h2 id="七集合遍历的那些坑">&lt;a href="#%e4%b8%83%e9%9b%86%e5%90%88%e9%81%8d%e5%8e%86%e7%9a%84%e9%82%a3%e4%ba%9b%e5%9d%91" class="header-anchor">&lt;/a>七、集合遍历的那些坑
&lt;/h2>&lt;h3 id="71-fail-fast-机制">&lt;a href="#71-fail-fast-%e6%9c%ba%e5%88%b6" class="header-anchor">&lt;/a>7.1 Fail-Fast 机制
&lt;/h3>&lt;p>ArrayList 内部有个 modCount（修改计数器），每次 add/remove 都会加 1。迭代器创建时会拍一个快照 expectedModCount。&lt;/p>
&lt;p>每次调 next() 都会对比这两个值。如果你在 foreach 里直接调 list.remove()，modCount 变了但 expectedModCount 没变，不一致就抛 ConcurrentModificationException。&lt;/p>
&lt;p>&lt;strong>漏网之鱼&lt;/strong>：删除倒数第二个元素时可能不报错。因为删除后 size 减 1，恰好等于 cursor，hasNext() 返回 false，循环正常结束，next() 里的校验根本没机会执行。但最后一个元素会被跳过，数据遗漏。&lt;/p>
&lt;p>Fail-Fast 不能作为可靠的并发检测手段。modCount 没有 volatile 修饰，多线程下有可见性问题。&lt;/p>
&lt;h3 id="72-普通-for-循环正序删除的坑">&lt;a href="#72-%e6%99%ae%e9%80%9a-for-%e5%be%aa%e7%8e%af%e6%ad%a3%e5%ba%8f%e5%88%a0%e9%99%a4%e7%9a%84%e5%9d%91" class="header-anchor">&lt;/a>7.2 普通 for 循环正序删除的坑
&lt;/h3>&lt;p>普通 for 循环没有 Fail-Fast，不会抛异常。但正序遍历删除会&lt;strong>静默跳过元素&lt;/strong>。&lt;/p>
&lt;p>因为删除一个元素后，后面的元素全部前移。但 i 继续递增，直接跳过了前移过来的那个元素。&lt;/p>
&lt;p>解决方案：倒序遍历（从后往前删），或者用 Java 8 的 removeIf。&lt;/p>
&lt;h3 id="73-安全删除元素的三种正确姿势">&lt;a href="#73-%e5%ae%89%e5%85%a8%e5%88%a0%e9%99%a4%e5%85%83%e7%b4%a0%e7%9a%84%e4%b8%89%e7%a7%8d%e6%ad%a3%e7%a1%ae%e5%a7%bf%e5%8a%bf" class="header-anchor">&lt;/a>7.3 安全删除元素的三种正确姿势
&lt;/h3>&lt;ol>
&lt;li>&lt;code>iterator.remove()&lt;/code>：迭代器自己的 remove 方法会同步更新 expectedModCount。&lt;/li>
&lt;li>倒序 for 循环：后面的元素前移不影响前面还没遍历的元素。&lt;/li>
&lt;li>&lt;code>list.removeIf(predicate)&lt;/code>：底层做了严格的边界控制，最安全最优雅。&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="八sublist-的视图陷阱">&lt;a href="#%e5%85%absublist-%e7%9a%84%e8%a7%86%e5%9b%be%e9%99%b7%e9%98%b1" class="header-anchor">&lt;/a>八、SubList 的视图陷阱
&lt;/h2>&lt;p>subList 不是创建新集合！它返回的内部类持有原 ArrayList 的引用，只是加了 offset 和 size 做可视窗口。&lt;/p>
&lt;ul>
&lt;li>改 sub → 原 list 跟着变。&lt;/li>
&lt;li>改原 list → 再访问 sub 直接报 ConcurrentModificationException（版本校验失败）。&lt;/li>
&lt;/ul>
&lt;p>正确做法：如果需要独立副本，套一层 &lt;code>new ArrayList&amp;lt;&amp;gt;(list.subList(2, 5))&lt;/code>。&lt;/p>
&lt;p>subList 最优雅的用法是范围清除：&lt;code>list.subList(2, 5).clear()&lt;/code>。&lt;/p>
&lt;hr>
&lt;h2 id="九arraysaslist-的三个致命坑">&lt;a href="#%e4%b9%9darraysaslist-%e7%9a%84%e4%b8%89%e4%b8%aa%e8%87%b4%e5%91%bd%e5%9d%91" class="header-anchor">&lt;/a>九、Arrays.asList 的三个致命坑
&lt;/h2>&lt;h3 id="坑一不能增删">&lt;a href="#%e5%9d%91%e4%b8%80%e4%b8%8d%e8%83%bd%e5%a2%9e%e5%88%a0" class="header-anchor">&lt;/a>坑一：不能增删
&lt;/h3>&lt;p>返回的不是 java.util.ArrayList，而是 Arrays 类的内部类。这个内部类没有重写 add 和 remove，调了直接 UnsupportedOperationException。&lt;/p>
&lt;h3 id="坑二引用传递后门">&lt;a href="#%e5%9d%91%e4%ba%8c%e5%bc%95%e7%94%a8%e4%bc%a0%e9%80%92%e5%90%8e%e9%97%a8" class="header-anchor">&lt;/a>坑二：引用传递后门
&lt;/h3>&lt;p>底层直接 &lt;code>this.a = array&lt;/code> 持有了原数组的引用。改原数组，List 跟着变；改 List，原数组也变。&lt;/p>
&lt;h3 id="坑三基础类型泛型灾难">&lt;a href="#%e5%9d%91%e4%b8%89%e5%9f%ba%e7%a1%80%e7%b1%bb%e5%9e%8b%e6%b3%9b%e5%9e%8b%e7%81%be%e9%9a%be" class="header-anchor">&lt;/a>坑三：基础类型泛型灾难
&lt;/h3>&lt;p>泛型只能是引用类型。传入 &lt;code>int[] arr&lt;/code>，整个数组被当成一个 Object 塞进去，得到 &lt;code>List&amp;lt;int[]&amp;gt;&lt;/code>，size 永远是 1。&lt;/p>
&lt;p>正确做法：&lt;/p>
&lt;ul>
&lt;li>对象数组：&lt;code>new ArrayList&amp;lt;&amp;gt;(Arrays.asList(arr))&lt;/code>&lt;/li>
&lt;li>基础类型数组：&lt;code>Arrays.stream(arr).boxed().collect(Collectors.toList())&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>List.of()（JDK 9+）创建的是完全不可变集合，add/set/remove 全部报错，连 null 都不能存。&lt;/p>
&lt;hr>
&lt;h2 id="十java-集合-vs-redis-数据结构">&lt;a href="#%e5%8d%81java-%e9%9b%86%e5%90%88-vs-redis-%e6%95%b0%e6%8d%ae%e7%bb%93%e6%9e%84" class="header-anchor">&lt;/a>十、Java 集合 vs Redis 数据结构
&lt;/h2>&lt;p>它们在逻辑模型上高度一致（List、Set、Map），但底层实现天差地别，因为运行环境不同。&lt;/p>
&lt;p>&lt;strong>哈希冲突&lt;/strong>：Java HashMap 用红黑树优化查询；Redis Hash 只有链表没有树（省内存、单线程维护树太复杂）。&lt;/p>
&lt;p>&lt;strong>扩容机制&lt;/strong>：Java 一次性全量搬运（多线程环境扛得住瞬间卡顿）；Redis 用渐进式 Rehash 分批搬运（单线程不能卡，每次只搬一个桶）。&lt;/p>
&lt;p>&lt;strong>有序集合&lt;/strong>：Java TreeSet 用红黑树；Redis ZSet 用跳表（范围查找更快、实现更简单）。&lt;/p>
&lt;p>&lt;strong>并发安全&lt;/strong>：Java 需要 ConcurrentHashMap 用 CAS+锁来保证；Redis 天然安全（单线程执行命令）。&lt;/p>
&lt;p>脱离运行环境谈数据结构选型就是耍流氓。底层设计的本质就是空间与时间、并发与单线程的极限取舍。&lt;/p></description></item><item><title>Redis 持久化机制：RDB 与 AOF 的深入对比</title><link>https://blog.canggo.com/p/redis-persistence/</link><pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog.canggo.com/p/redis-persistence/</guid><description>&lt;p>Redis 作为内存数据库，进程退出后数据就会丢失。持久化是保证数据安全的核心能力。Redis 提供了两种持久化方式：&lt;strong>RDB（快照）&lt;/strong> 和 &lt;strong>AOF（追加日志）&lt;/strong>。&lt;/p>
&lt;p>在我的项目中，Redis 同时承担了缓存、分布式锁和会话存储的角色，选择合适的持久化策略直接影响数据安全性和性能。&lt;/p>
&lt;h2 id="rdbredis-database">&lt;a href="#rdbredis-database" class="header-anchor">&lt;/a>RDB（Redis Database）
&lt;/h2>&lt;h3 id="原理">&lt;a href="#%e5%8e%9f%e7%90%86" class="header-anchor">&lt;/a>原理
&lt;/h3>&lt;p>RDB 通过生成某个时间点的&lt;strong>全量数据快照&lt;/strong>来实现持久化。Redis fork 一个子进程，子进程遍历内存数据写入临时文件，完成后原子替换旧文件。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">主进程 子进程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── fork() ────────────────&amp;gt;│
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── 遍历内存数据
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ 继续处理客户端请求 ├── 写入临时 rdb 文件
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── 替换旧 rdb 文件
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── 退出
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>fork 使用操作系统的 &lt;strong>Copy-on-Write&lt;/strong> 机制：子进程与父进程共享内存页，只有当父进程修改某页数据时才会复制该页。因此 fork 本身很快，但如果在快照期间有大量写入，内存占用会显著上升。&lt;/p>
&lt;h3 id="触发方式">&lt;a href="#%e8%a7%a6%e5%8f%91%e6%96%b9%e5%bc%8f" class="header-anchor">&lt;/a>触发方式
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 手动触发&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SAVE &lt;span class="c1"># 同步阻塞，生产环境禁用&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">BGSAVE &lt;span class="c1"># 异步 fork 子进程&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 自动触发（redis.conf）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">save &lt;span class="m">900&lt;/span> &lt;span class="m">1&lt;/span> &lt;span class="c1"># 900 秒内至少 1 次修改&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">save &lt;span class="m">300&lt;/span> &lt;span class="m">10&lt;/span> &lt;span class="c1"># 300 秒内至少 10 次修改&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">save &lt;span class="m">60&lt;/span> &lt;span class="m">10000&lt;/span> &lt;span class="c1"># 60 秒内至少 10000 次修改&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="优缺点">&lt;a href="#%e4%bc%98%e7%bc%ba%e7%82%b9" class="header-anchor">&lt;/a>优缺点
&lt;/h3>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>✅ 优点&lt;/th>
 &lt;th>❌ 缺点&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>文件紧凑，适合备份和灾难恢复&lt;/td>
 &lt;td>两次快照之间的数据可能丢失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>恢复速度快（直接加载二进制）&lt;/td>
 &lt;td>数据量大时 fork 耗时较长&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>对主线程影响小&lt;/td>
 &lt;td>fork 瞬间内存可能翻倍（COW）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="aofappend-only-file">&lt;a href="#aofappend-only-file" class="header-anchor">&lt;/a>AOF（Append Only File）
&lt;/h2>&lt;h3 id="原理-1">&lt;a href="#%e5%8e%9f%e7%90%86-1" class="header-anchor">&lt;/a>原理
&lt;/h3>&lt;p>AOF 记录每一条写命令，追加到文件末尾。重启时重放所有命令来恢复数据。&lt;/p>
&lt;h3 id="同步策略">&lt;a href="#%e5%90%8c%e6%ad%a5%e7%ad%96%e7%95%a5" class="header-anchor">&lt;/a>同步策略
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">appendfsync always &lt;span class="c1"># 每条命令都同步，最安全，性能最差&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">appendfsync everysec &lt;span class="c1"># 每秒同步（推荐，最多丢 1 秒数据）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">appendfsync no &lt;span class="c1"># 由 OS 决定，性能最好，可靠性最差&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="aof-重写">&lt;a href="#aof-%e9%87%8d%e5%86%99" class="header-anchor">&lt;/a>AOF 重写
&lt;/h3>&lt;p>AOF 文件会不断膨胀，Redis 通过&lt;strong>重写机制&lt;/strong>压缩文件体积：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">原始 AOF: 重写后:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SET counter 1 SET counter 4
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">INCR counter ← 等效合并
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">INCR counter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">INCR counter
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>重写同样通过 fork 子进程执行，不阻塞主线程。&lt;/p>
&lt;h3 id="优缺点-1">&lt;a href="#%e4%bc%98%e7%bc%ba%e7%82%b9-1" class="header-anchor">&lt;/a>优缺点
&lt;/h3>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>✅ 优点&lt;/th>
 &lt;th>❌ 缺点&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>数据安全性高（最多丢 1 秒）&lt;/td>
 &lt;td>文件体积通常比 RDB 大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>可读性好，便于分析和调试&lt;/td>
 &lt;td>恢复速度慢（重放命令）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重写机制控制文件大小&lt;/td>
 &lt;td>持续写入有 I/O 压力&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="对比总结">&lt;a href="#%e5%af%b9%e6%af%94%e6%80%bb%e7%bb%93" class="header-anchor">&lt;/a>对比总结
&lt;/h2>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>维度&lt;/th>
 &lt;th>RDB&lt;/th>
 &lt;th>AOF&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>数据安全&lt;/strong>&lt;/td>
 &lt;td>可能丢失分钟级数据&lt;/td>
 &lt;td>最多丢失 1 秒&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>文件大小&lt;/strong>&lt;/td>
 &lt;td>小（二进制压缩）&lt;/td>
 &lt;td>大（文本命令）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>恢复速度&lt;/strong>&lt;/td>
 &lt;td>快&lt;/td>
 &lt;td>慢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>性能影响&lt;/strong>&lt;/td>
 &lt;td>fork 时短暂影响&lt;/td>
 &lt;td>持续写入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>适用场景&lt;/strong>&lt;/td>
 &lt;td>定期备份、容灾&lt;/td>
 &lt;td>数据安全要求高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="生产环境推荐混合持久化">&lt;a href="#%e7%94%9f%e4%ba%a7%e7%8e%af%e5%a2%83%e6%8e%a8%e8%8d%90%e6%b7%b7%e5%90%88%e6%8c%81%e4%b9%85%e5%8c%96" class="header-anchor">&lt;/a>生产环境推荐：混合持久化
&lt;/h2>&lt;p>Redis 4.0+ 支持 &lt;strong>RDB + AOF 混合持久化&lt;/strong>：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">aof-use-rdb-preamble yes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>重写时先以 RDB 格式写入全量数据，再追加增量 AOF 命令。&lt;strong>兼顾恢复速度和数据安全&lt;/strong>。&lt;/p>

 &lt;blockquote>
 &lt;p>追求性能用 RDB，追求安全用 AOF，生产环境开混合持久化。&lt;/p>
 &lt;/blockquote></description></item></channel></rss>