<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://niuteng5618.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://niuteng5618.github.io/" rel="alternate" type="text/html" /><updated>2026-06-03T10:13:10+00:00</updated><id>https://niuteng5618.github.io/feed.xml</id><title type="html">niuteng5618’s blog</title><subtitle>niuteng5618 的个人博客</subtitle><entry><title type="html">Claude Code System Prompt 的运行时组装逻辑</title><link href="https://niuteng5618.github.io/073-claude-code-system-prompt/" rel="alternate" type="text/html" title="Claude Code System Prompt 的运行时组装逻辑" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://niuteng5618.github.io/073-claude-code-system-prompt</id><content type="html" xml:base="https://niuteng5618.github.io/073-claude-code-system-prompt/"><![CDATA[<h1 id="claude-code-system-prompt-的运行时组装逻辑">Claude Code System Prompt 的运行时组装逻辑</h1>

<p>Claude Code 的 system prompt 不应理解为一段写死的大字符串，而应理解为<strong>运行时由多个 section 组合出来的执行契约</strong>。它的目标不是“把所有说明都塞给模型”，而是在每一轮请求中，精确提供当前 Agent 需要的身份、能力、约束和上下文。</p>

<h2 id="核心思想">核心思想</h2>

<p>System prompt 的工程化原则是：</p>

<ol>
  <li><strong>分段维护</strong>：不同职责进入不同 section，避免一处修改影响全局。</li>
  <li><strong>按状态加载</strong>：是否注入某段内容，取决于真实运行态，而不是关键词猜测。</li>
  <li><strong>稳定优先</strong>：稳定内容尽量保持顺序和文本不变，利于 prompt cache 命中。</li>
  <li><strong>动态隔离</strong>：易变内容放在动态边界之后，避免污染静态缓存。</li>
  <li><strong>上下文最小化</strong>：只加载当前任务必要的信息，减少噪声和 token 成本。</li>
</ol>

<h2 id="claude-code-system-prompt-的主要-section">Claude Code System Prompt 的主要 Section</h2>

<p>Claude Code 中 section 数量不是固定值，会受模式、功能开关、工具集、MCP、用户配置、项目状态等影响，一些section静态插入，一些动态插入。可以按职责划分为以下几类。</p>

<h3 id="1-identity--role">1. Identity / Role</h3>

<p>定义模型当前扮演的角色：它不是普通聊天助手，而是运行在开发环境中的 coding agent。</p>

<p>典型内容包括：</p>

<ul>
  <li>当前身份是什么</li>
  <li>面向软件工程任务工作</li>
  <li>可以读取、修改、运行、验证代码</li>
  <li>需要主动完成任务，而不是只给建议</li>
</ul>

<p>这一段通常属于稳定 section，几乎每轮都存在。</p>

<h3 id="2-core-behavior--agent-contract">2. Core Behavior / Agent Contract</h3>

<p>定义 Agent 的基本行为契约。</p>

<p>关注点包括：</p>

<ul>
  <li>任务未完成时继续推进</li>
  <li>不要在可以行动时只解释</li>
  <li>根据工具结果更新判断</li>
  <li>失败后调整策略</li>
  <li>完成后给出明确结果</li>
</ul>

<p>这部分决定 Agent 的“工作方式”，不是某个具体工具的说明。</p>

<h3 id="3-tool-use">3. Tool Use</h3>

<p>说明工具使用原则，而不是复制所有工具 schema。</p>

<p>工具 schema 通常通过 API 的 <code class="language-plaintext highlighter-rouge">tools</code> 参数传入；system prompt 中更关注高层规则：</p>

<ul>
  <li>什么时候应该用工具</li>
  <li>工具结果如何进入下一轮判断</li>
  <li>不要臆测文件内容、命令结果或环境状态</li>
  <li>对破坏性操作保持谨慎</li>
  <li>并行工具、后台任务、子 Agent 等能力的使用边界</li>
</ul>

<p>换句话说，<code class="language-plaintext highlighter-rouge">tools</code> 参数告诉模型“有哪些工具”；tool-use section 告诉模型“应该如何使用工具”。</p>

<h3 id="4-workspace--environment">4. Workspace / Environment</h3>

<p>描述当前工作环境。</p>

<p>常见内容包括：</p>

<ul>
  <li>当前工作目录</li>
  <li>操作系统、shell、平台信息</li>
  <li>git 仓库状态</li>
  <li>默认分支和当前分支</li>
  <li>近期提交或工作区改动</li>
  <li>运行环境限制</li>
</ul>

<p>这类信息来自真实环境探测，不应由模型从用户话语中猜测。</p>

<h3 id="5-project-instructions">5. Project Instructions</h3>

<p>项目级指令通常来自 <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>、项目配置、仓库约定或用户持久偏好。</p>

<p>它解决的问题是：</p>

<ul>
  <li>当前项目如何构建、测试、发布</li>
  <li>代码风格和命名约定</li>
  <li>哪些目录不能动</li>
  <li>哪些命令危险或需要确认</li>
  <li>团队偏好的工作流程</li>
</ul>

<p>这部分属于“项目知识”，不是模型通用能力。它应独立于核心身份 section，便于按项目切换和缓存管理。</p>

<h3 id="6-memory">6. Memory</h3>

<p>Memory section 注入跨会话保留的信息。</p>

<p>它通常不是完整记忆库，而是经过选择后的相关记忆，例如：</p>

<ul>
  <li>用户长期偏好</li>
  <li>项目长期约束</li>
  <li>已确认的工作方式</li>
  <li>外部资源入口</li>
</ul>

<p>关键点：memory 应该<strong>选择性注入</strong>。把所有记忆全部放入 system prompt 会降低精度，并增加冲突风险。</p>

<h3 id="7-skills--commands">7. Skills / Commands</h3>

<p>Skill 不会在启动时把所有 <code class="language-plaintext highlighter-rouge">SKILL.md</code> 全文注入。更专业的方式是两级加载：</p>

<ol>
  <li>启动时只注入技能目录：<code class="language-plaintext highlighter-rouge">name + description</code></li>
  <li>需要时再加载完整 skill 内容</li>
</ol>

<p>因此 system prompt 里通常只出现“有哪些 skill 可用、何时使用”的目录信息。完整技能说明进入上下文，应由模型显式触发加载。</p>

<p>这避免了把大量低频专业知识长期占据上下文。</p>

<h3 id="8-mcp-instructions">8. MCP Instructions</h3>

<p>MCP server 会带来外部工具、资源和提示。由于 MCP 连接可能在会话中变化，MCP 相关 section 通常更动态。</p>

<p>其内容可能包括：</p>

<ul>
  <li>当前连接了哪些 MCP server</li>
  <li>暴露了哪些外部能力</li>
  <li>资源访问方式</li>
  <li>特定 server 的使用约束</li>
</ul>

<p>这类 section 易变，不适合和稳定核心 prompt 绑定在同一缓存块中。</p>

<h3 id="9-output-style--response-policy">9. Output Style / Response Policy</h3>

<p>控制输出形式。</p>

<p>常见约束包括：</p>

<ul>
  <li>回答简洁还是详细</li>
  <li>是否使用 Markdown</li>
  <li>是否引用文件路径和行号</li>
  <li>代码修改后的汇报格式</li>
  <li>是否避免无意义总结</li>
  <li>是否优先给 CLI 命令</li>
</ul>

<p>Output style 不是模型身份，而是当前交互界面的表达协议。</p>

<h3 id="10-context-management">10. Context Management</h3>

<p>Claude Code 需要处理长会话，所以 system prompt 还会包含上下文管理相关指令。</p>

<p>典型逻辑包括：</p>

<ul>
  <li>何时压缩上下文</li>
  <li>如何处理大工具输出</li>
  <li>哪些信息应保留</li>
  <li>哪些历史可以摘要</li>
  <li>如何避免把无关日志长期留在上下文中</li>
</ul>

<p>这部分让 Agent 在长任务中保持可控，而不是被历史噪声淹没。</p>

<h3 id="11-safety--permission-model">11. Safety / Permission Model</h3>

<p>安全和权限不会只依赖 prompt，但 prompt 会描述模型应如何配合 harness 的权限系统。</p>

<p>例如：</p>

<ul>
  <li>修改文件前理解上下文</li>
  <li>删除、覆盖、发布、推送等操作需要谨慎</li>
  <li>外部服务调用具有副作用</li>
  <li>不绕过用户确认</li>
  <li>不把 hook 或配置当成绝对授权</li>
</ul>

<p>真正的权限判断应由 harness 执行；system prompt 负责让模型形成正确行为倾向。</p>

<h2 id="static-section-与-dynamic-section">Static Section 与 Dynamic Section</h2>

<p>Claude Code 的专业点在于：它不是简单拼接字符串，而是区分稳定内容和易变内容。</p>

<h3 id="顺序有意义静态在前动态在后">顺序有意义：静态在前，动态在后</h3>

<p>静态 prompt 和动态 prompt 不是随意拼接，顺序有工程意义。更合理的结构是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>静态 section
  ↓
动态边界
  ↓
动态 section
</code></pre></div></div>

<p>静态 section 放在前面，主要有三层作用：</p>

<ol>
  <li><strong>提高 prompt cache 命中率</strong>：API 级 prompt cache 通常更容易复用稳定前缀。身份、行为契约、工具使用原则等内容如果长期保持在前缀位置，就能在不同轮次、不同任务中复用。动态内容如果放在前面，每次 git 状态、memory、MCP 状态变化都会污染前缀，降低缓存收益。</li>
  <li><strong>隔离易变信息</strong>：动态 section 放在边界之后，当前目录、项目状态、memory、MCP instructions、token budget 等变化不会影响静态核心块。</li>
  <li><strong>建立解释优先级</strong>：模型先看到稳定的角色、边界和行为契约，再看到当前环境信息。这样动态上下文是在核心规则下被解释，而不是反过来让临时状态改写 Agent 的基本行为。</li>
</ol>

<p>因此，静态在前不只是为了省 token，也是在维护 system prompt 的稳定语义结构：<strong>先定义 Agent 是什么，再描述 Agent 当前身处什么环境。</strong></p>

<h3 id="static-section">Static Section</h3>

<p>静态 section 通常包括：</p>

<ul>
  <li>identity</li>
  <li>core behavior</li>
  <li>tool-use general policy</li>
  <li>tone / output efficiency</li>
  <li>基础 agent contract</li>
</ul>

<p>这些内容变化少，适合放入稳定缓存块。</p>

<h3 id="dynamic-section">Dynamic Section</h3>

<p>动态 section 通常包括：</p>

<ul>
  <li>当前目录和环境</li>
  <li>git 状态</li>
  <li>memory 选择结果</li>
  <li>CLAUDE.md / 项目指令</li>
  <li>MCP 连接信息</li>
  <li>output style</li>
  <li>token budget</li>
  <li>当前模式或 feature flag</li>
</ul>

<p>这些内容可能随会话、项目、工具状态变化，因此应和静态 prompt 分离。</p>

<p>核心收益：<strong>动态内容变化时，不让整个 system prompt 的缓存全部失效。</strong></p>

<h2 id="system-context-与-user-context-的区别">System Context 与 User Context 的区别</h2>

<p>Claude Code 中并非所有“系统提醒”都直接属于 system prompt。</p>

<p>可以粗略分为：</p>

<table>
  <thead>
    <tr>
      <th>类型</th>
      <th>作用</th>
      <th>注入方式</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>System Context</td>
      <td>描述运行环境、工具契约、核心行为</td>
      <td>system prompt sections</td>
    </tr>
    <tr>
      <td>User Context</td>
      <td>当前日期、项目提醒、CLAUDE.md、git 状态等上下文提示</td>
      <td>常以 system-reminder 形式进入消息流</td>
    </tr>
  </tbody>
</table>

<p>这种区分很重要：</p>

<ul>
  <li>system prompt 更像稳定协议</li>
  <li>user-context reminder 更像当前轮的环境观测</li>
</ul>

<p>不要把所有信息都塞进 system prompt；不同生命周期的信息应进入不同通道。</p>

<h2 id="运行时组装流程">运行时组装流程</h2>

<p>专业实现可以抽象为：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>读取基础配置
  ↓
探测当前环境和项目状态
  ↓
加载用户 / 项目指令
  ↓
扫描可用工具、skills、MCP
  ↓
选择需要的 section
  ↓
按稳定顺序组装
  ↓
在静态 / 动态边界处分块
  ↓
发送给模型
</code></pre></div></div>

<p>这里最重要的是“选择 section 的依据”。</p>

<p>好的逻辑应基于真实状态：</p>

<ul>
  <li>工具是否实际注册</li>
  <li>MCP 是否实际连接</li>
  <li>memory 是否被选中</li>
  <li>项目指令文件是否存在</li>
  <li>当前模式是否启用</li>
  <li>token 预算是否变化</li>
</ul>

<p>而不是基于用户消息里是否出现某个关键词。</p>

<h2 id="为什么不能硬编码">为什么不能硬编码</h2>

<p>硬编码 system prompt 的问题不在于“字符串长”，而在于它破坏了可维护性：</p>

<ol>
  <li><strong>职责混杂</strong>：身份、工具、记忆、项目规则混在一起。</li>
  <li><strong>冲突难定位</strong>：新增一段指令可能影响远处行为。</li>
  <li><strong>缓存不稳定</strong>：动态内容变化导致整体缓存失效。</li>
  <li><strong>项目不可迁移</strong>：换仓库后难以判断哪些内容该保留。</li>
  <li><strong>上下文噪声大</strong>：低频能力长期污染高频任务。</li>
</ol>

<p>所以 system prompt 应该被看作配置产物，而不是手写常量。</p>

<h2 id="设计准则">设计准则</h2>

<p>构建 Agent harness 时，可以遵循以下准则：</p>

<ul>
  <li>核心身份稳定，项目上下文动态</li>
  <li>工具能力由真实注册表生成</li>
  <li>skill 只先暴露目录，按需加载全文</li>
  <li>memory 必须筛选后注入</li>
  <li>MCP section 单独处理，避免污染静态缓存</li>
  <li>section 顺序保持确定性</li>
  <li>高风险规则交给权限系统兜底，不只依赖 prompt</li>
  <li>prompt 只描述行为契约，不承载所有业务知识</li>
</ul>

<h2 id="总结">总结</h2>

<p>Claude Code 的 system prompt 不是“提示词技巧”，而是 harness 的运行时配置层。</p>

<p>它把 Agent 的身份、工具原则、工作环境、项目规则、记忆、技能、MCP、输出风格和安全约束拆成独立 section，再根据当前状态组装。</p>

<p>这就是“system prompt 运行时组装，而不是硬编码”的核心。</p>]]></content><author><name>niuteng5618</name></author><category term="智能体应用开发" /><category term="AI 编程工具" /><category term="Claude Code / Codex" /><category term="Claude Code / Codex" /><category term="Claude Code" /><category term="System Prompt" /><category term="Prompt Cache" /><category term="Agent" /><summary type="html"><![CDATA[Claude Code System Prompt 的运行时组装逻辑 Claude Code 的 system prompt 不应理解为一段写死的大字符串，而应理解为运行时由多个 section 组合出来的执行契约。它的目标不是“把所有说明都塞给模型”，而是在每一轮请求中，精确提供当前 Agent 需要的身份、能力、约束和上下文。 核心思想 System prompt 的工程化原则是： 分段维护：不同职责进入不同 section，避免一处修改影响全局。 按状态加载：是否注入某段内容，取决于真实运行态，而不是关键词猜测。 稳定优先：稳定内容尽量保持顺序和文本不变，利于 prompt cache 命中。 动态隔离：易变内容放在动态边界之后，避免污染静态缓存。 上下文最小化：只加载当前任务必要的信息，减少噪声和 token 成本。 Claude Code System Prompt 的主要 Section Claude Code 中 section 数量不是固定值，会受模式、功能开关、工具集、MCP、用户配置、项目状态等影响，一些section静态插入，一些动态插入。可以按职责划分为以下几类。 1. Identity / Role 定义模型当前扮演的角色：它不是普通聊天助手，而是运行在开发环境中的 coding agent。 典型内容包括： 当前身份是什么 面向软件工程任务工作 可以读取、修改、运行、验证代码 需要主动完成任务，而不是只给建议 这一段通常属于稳定 section，几乎每轮都存在。 2. Core Behavior / Agent Contract 定义 Agent 的基本行为契约。 关注点包括： 任务未完成时继续推进 不要在可以行动时只解释 根据工具结果更新判断 失败后调整策略 完成后给出明确结果 这部分决定 Agent 的“工作方式”，不是某个具体工具的说明。 3. Tool Use 说明工具使用原则，而不是复制所有工具 schema。 工具 schema 通常通过 API 的 tools 参数传入；system prompt 中更关注高层规则： 什么时候应该用工具 工具结果如何进入下一轮判断 不要臆测文件内容、命令结果或环境状态 对破坏性操作保持谨慎 并行工具、后台任务、子 Agent 等能力的使用边界 换句话说，tools 参数告诉模型“有哪些工具”；tool-use section 告诉模型“应该如何使用工具”。 4. Workspace / Environment 描述当前工作环境。 常见内容包括： 当前工作目录 操作系统、shell、平台信息 git 仓库状态 默认分支和当前分支 近期提交或工作区改动 运行环境限制 这类信息来自真实环境探测，不应由模型从用户话语中猜测。 5. Project Instructions 项目级指令通常来自 CLAUDE.md、项目配置、仓库约定或用户持久偏好。 它解决的问题是： 当前项目如何构建、测试、发布 代码风格和命名约定 哪些目录不能动 哪些命令危险或需要确认 团队偏好的工作流程 这部分属于“项目知识”，不是模型通用能力。它应独立于核心身份 section，便于按项目切换和缓存管理。 6. Memory Memory section 注入跨会话保留的信息。 它通常不是完整记忆库，而是经过选择后的相关记忆，例如： 用户长期偏好 项目长期约束 已确认的工作方式 外部资源入口 关键点：memory 应该选择性注入。把所有记忆全部放入 system prompt 会降低精度，并增加冲突风险。 7. Skills / Commands Skill 不会在启动时把所有 SKILL.md 全文注入。更专业的方式是两级加载： 启动时只注入技能目录：name + description 需要时再加载完整 skill 内容 因此 system prompt 里通常只出现“有哪些 skill 可用、何时使用”的目录信息。完整技能说明进入上下文，应由模型显式触发加载。 这避免了把大量低频专业知识长期占据上下文。 8. MCP Instructions MCP server 会带来外部工具、资源和提示。由于 MCP 连接可能在会话中变化，MCP 相关 section 通常更动态。 其内容可能包括： 当前连接了哪些 MCP server 暴露了哪些外部能力 资源访问方式 特定 server 的使用约束 这类 section 易变，不适合和稳定核心 prompt 绑定在同一缓存块中。 9. Output Style / Response Policy 控制输出形式。 常见约束包括： 回答简洁还是详细 是否使用 Markdown 是否引用文件路径和行号 代码修改后的汇报格式 是否避免无意义总结 是否优先给 CLI 命令 Output style 不是模型身份，而是当前交互界面的表达协议。 10. Context Management Claude Code 需要处理长会话，所以 system prompt 还会包含上下文管理相关指令。 典型逻辑包括： 何时压缩上下文 如何处理大工具输出 哪些信息应保留 哪些历史可以摘要 如何避免把无关日志长期留在上下文中 这部分让 Agent 在长任务中保持可控，而不是被历史噪声淹没。 11. Safety / Permission Model 安全和权限不会只依赖 prompt，但 prompt 会描述模型应如何配合 harness 的权限系统。 例如： 修改文件前理解上下文 删除、覆盖、发布、推送等操作需要谨慎 外部服务调用具有副作用 不绕过用户确认 不把 hook 或配置当成绝对授权 真正的权限判断应由 harness 执行；system prompt 负责让模型形成正确行为倾向。 Static Section 与 Dynamic Section Claude Code 的专业点在于：它不是简单拼接字符串，而是区分稳定内容和易变内容。 顺序有意义：静态在前，动态在后 静态 prompt 和动态 prompt 不是随意拼接，顺序有工程意义。更合理的结构是： 静态 section ↓ 动态边界 ↓ 动态 section 静态 section 放在前面，主要有三层作用： 提高 prompt cache 命中率：API 级 prompt cache 通常更容易复用稳定前缀。身份、行为契约、工具使用原则等内容如果长期保持在前缀位置，就能在不同轮次、不同任务中复用。动态内容如果放在前面，每次 git 状态、memory、MCP 状态变化都会污染前缀，降低缓存收益。 隔离易变信息：动态 section 放在边界之后，当前目录、项目状态、memory、MCP instructions、token budget 等变化不会影响静态核心块。 建立解释优先级：模型先看到稳定的角色、边界和行为契约，再看到当前环境信息。这样动态上下文是在核心规则下被解释，而不是反过来让临时状态改写 Agent 的基本行为。 因此，静态在前不只是为了省 token，也是在维护 system prompt 的稳定语义结构：先定义 Agent 是什么，再描述 Agent 当前身处什么环境。 Static Section 静态 section 通常包括： identity core behavior tool-use general policy tone / output efficiency 基础 agent contract 这些内容变化少，适合放入稳定缓存块。 Dynamic Section 动态 section 通常包括： 当前目录和环境 git 状态 memory 选择结果 CLAUDE.md / 项目指令 MCP 连接信息 output style token budget 当前模式或 feature flag 这些内容可能随会话、项目、工具状态变化，因此应和静态 prompt 分离。 核心收益：动态内容变化时，不让整个 system prompt 的缓存全部失效。 System Context 与 User Context 的区别 Claude Code 中并非所有“系统提醒”都直接属于 system prompt。 可以粗略分为： 类型 作用 注入方式 System Context 描述运行环境、工具契约、核心行为 system prompt sections User Context 当前日期、项目提醒、CLAUDE.md、git 状态等上下文提示 常以 system-reminder 形式进入消息流 这种区分很重要： system prompt 更像稳定协议 user-context reminder 更像当前轮的环境观测 不要把所有信息都塞进 system prompt；不同生命周期的信息应进入不同通道。 运行时组装流程 专业实现可以抽象为： 读取基础配置 ↓ 探测当前环境和项目状态 ↓ 加载用户 / 项目指令 ↓ 扫描可用工具、skills、MCP ↓ 选择需要的 section ↓ 按稳定顺序组装 ↓ 在静态 / 动态边界处分块 ↓ 发送给模型 这里最重要的是“选择 section 的依据”。 好的逻辑应基于真实状态： 工具是否实际注册 MCP 是否实际连接 memory 是否被选中 项目指令文件是否存在 当前模式是否启用 token 预算是否变化 而不是基于用户消息里是否出现某个关键词。 为什么不能硬编码 硬编码 system prompt 的问题不在于“字符串长”，而在于它破坏了可维护性： 职责混杂：身份、工具、记忆、项目规则混在一起。 冲突难定位：新增一段指令可能影响远处行为。 缓存不稳定：动态内容变化导致整体缓存失效。 项目不可迁移：换仓库后难以判断哪些内容该保留。 上下文噪声大：低频能力长期污染高频任务。 所以 system prompt 应该被看作配置产物，而不是手写常量。 设计准则 构建 Agent harness 时，可以遵循以下准则： 核心身份稳定，项目上下文动态 工具能力由真实注册表生成 skill 只先暴露目录，按需加载全文 memory 必须筛选后注入 MCP section 单独处理，避免污染静态缓存 section 顺序保持确定性 高风险规则交给权限系统兜底，不只依赖 prompt prompt 只描述行为契约，不承载所有业务知识 总结 Claude Code 的 system prompt 不是“提示词技巧”，而是 harness 的运行时配置层。 它把 Agent 的身份、工具原则、工作环境、项目规则、记忆、技能、MCP、输出风格和安全约束拆成独立 section，再根据当前状态组装。 这就是“system prompt 运行时组装，而不是硬编码”的核心。]]></summary></entry><entry><title type="html">MCP 支持哪些传输协议？通俗讲解</title><link href="https://niuteng5618.github.io/074-mcp-transport/" rel="alternate" type="text/html" title="MCP 支持哪些传输协议？通俗讲解" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://niuteng5618.github.io/074-mcp-transport</id><content type="html" xml:base="https://niuteng5618.github.io/074-mcp-transport/"><![CDATA[<h2 id="️-mcp-支持哪些传输协议通俗讲解">🗂️ MCP 支持哪些传输协议？——通俗讲解</h2>

<h3 id="一先理解一个比喻快递是怎么送的">一、先理解一个比喻：快递是怎么送的？</h3>

<p>MCP（Model Context Protocol）是 AI 和工具之间通信的”规则”，但光有规则还不够，还需要选择<strong>运输方式</strong>，就像快递可以用摩托车、货车、飞机来运，这在 MCP 里叫 <strong>Transport（传输层）</strong>。</p>

<p>目前 MCP 官方支持两种正式传输方式，另有一种已被弃用：</p>

<hr />

<h3 id="二三种传输方式逐一解释">二、三种传输方式逐一解释</h3>

<h4 id="-1-stdio标准输入输出">🟢 1. stdio（标准输入输出）</h4>

<p><strong>生活比喻：</strong> 就像两个人面对面用纸条传话。</p>

<p>客户端把 MCP Server 当作一个<strong>子进程</strong>启动，Server 从 <code class="language-plaintext highlighter-rouge">stdin</code>（标准输入）读取消息，从 <code class="language-plaintext highlighter-rouge">stdout</code>（标准输出）发送消息，消息格式是 JSON-RPC，每行一条。</p>

<ul>
  <li>✅ 适合：<strong>本地运行</strong>，比如你在自己电脑上用 Claude Desktop 调用本地工具</li>
  <li>✅ 简单、零网络开销</li>
  <li>❌ 缺点：只能本地用，无法跨网络</li>
</ul>

<hr />

<h4 id="-2-httpsse旧版已弃用">🔴 2. HTTP+SSE（旧版，已弃用）</h4>

<p><strong>生活比喻：</strong> 你打了两部手机——一部专门接电话（收服务器消息），一部专门打出去（发请求给服务器），这很麻烦。</p>

<p>MCP 最初需要远程传输时（2024-11-05 规范），采用了 SSE 方案，使用<strong>两个端点</strong>：客户端通过 GET 连接到 <code class="language-plaintext highlighter-rouge">/sse</code> 端点来接收服务器的流式响应，然后通过 POST 发送请求到单独的 <code class="language-plaintext highlighter-rouge">/messages</code> 端点。</p>

<p><strong>为什么被弃用？</strong> 需要管理两个端点，服务器还要维护 SSE 连接 ID 和 POST 请求之间的映射关系，加重了有状态的复杂性，水平扩展很麻烦。另外很多负载均衡器、API 网关对长连接 SSE 的兼容性不好。</p>

<hr />

<h4 id="-3-streamable-http新版当前推荐">🟡 3. Streamable HTTP（新版，当前推荐）</h4>

<p><strong>生活比喻：</strong> 只用一个手机号，既能接电话又能打电话，服务器还可以选择”发短信”（普通 JSON 回复）或”打语音电话”（SSE 流式响应）。</p>

<p>Streamable HTTP 使用<strong>单一端点</strong>（如 <code class="language-plaintext highlighter-rouge">/mcp</code>），支持 POST 和 GET。客户端通过 POST 发送 JSON-RPC 消息；服务器可以选择回一个普通 JSON 响应，也可以升级为 SSE 流来处理耗时操作，不需要独立的”事件端点”。</p>

<blockquote>
  <p><strong>关键理解：</strong> SSE 这项技术本身并没有消失，它作为<strong>可选的流式响应方式</strong>被内嵌在 Streamable HTTP 里面了。被弃用的是”旧版双端点 HTTP+SSE 传输方案”，而不是 SSE 技术本身。</p>
</blockquote>

<hr />

<h3 id="三sse-被弃用怎么理解">三、SSE 被弃用？怎么理解？</h3>

<p>这是很多文章容易让人误解的地方，要区分两个层次：</p>

<table>
  <thead>
    <tr>
      <th>层次</th>
      <th>状态</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>SSE 技术本身</strong>（Server-Sent Events，服务器推送事件）</td>
      <td>✅ 没有弃用，仍在使用</td>
    </tr>
    <tr>
      <td><strong>旧版 HTTP+SSE 传输方案</strong>（MCP 2024-11-05 规范里的那个双端点设计）</td>
      <td>❌ 在 2025-03-26 规范中正式弃用</td>
    </tr>
  </tbody>
</table>

<p>旧版 HTTP+SSE 传输在 2025-03-26 规范中被正式弃用，新实现应使用 Streamable HTTP。但服务器为了兼容老客户端，可以继续保留旧端点。</p>

<p><strong>时间线：</strong></p>

<p>2024-11-05，MCP 初始规范，定义了 stdio 和 SSE 作为两种标准传输；2025-03-26，Streamable HTTP 引入，SSE 正式弃用；2025-11-25 最新规范中，正式的两种标准传输为 stdio 和 Streamable HTTP，SSE 仅保留用于向后兼容。</p>

<h4 id="一句话理解旧版httpsse和streamablehttp的差异"><strong>一句话理解<code class="language-plaintext highlighter-rouge">旧版HTTP+SSE</code>和StreamableHTTP的差异</strong></h4>

<ul>
  <li>旧版 <code class="language-plaintext highlighter-rouge">HTTP + SSE</code> 采用尴尬的<strong>双端点架构</strong>，AI 必须在一个端点通过 HTTP POST 发送指令，再从另一个独立的 SSE 端点接收流式响应，导致服务器必须依靠 Session ID 维持死板的“有状态”连接。</li>
  <li>新版 <code class="language-plaintext highlighter-rouge">Streamable HTTP</code> 彻底优化为<strong>单一标准 HTTP 端点</strong>，让 AI 的每一次工具调用都变成“同一个请求进去，流式响应直接出来”的<strong>无状态交互</strong>。这不仅斩断了 Session 的包袱，更彻底解决了旧版无法部署在云端 Serverless（无服务器）架构上、网络抖动易断线解绑的致命痛点。</li>
</ul>

<hr />

<h3 id="四它们和-websocket-的关系">四、它们和 WebSocket 的关系</h3>

<h4 id="sse-和-websocket-的核心区别">SSE 和 WebSocket 的核心区别</h4>

<p>SSE 是基于 HTTP 标准的<strong>单向通信</strong>技术，服务器可以向客户端推送数据，但客户端无法通过 SSE 反向发送数据；WebSocket 则是<strong>全双工</strong>协议，客户端和服务器可以同时互相发送接收消息，连接一旦建立就保持打开状态。</p>

<p>用生活比喻：</p>

<ul>
  <li><strong>SSE</strong> = 广播电台，只有电台（服务器）发声，听众（客户端）只能听</li>
  <li><strong>WebSocket</strong> = 对讲机，双方都可以随时说话</li>
</ul>

<h4 id="mcp-支持-websocket-吗">MCP 支持 WebSocket 吗？</h4>

<p><strong>不支持</strong>。WebSocket 不在当前 MCP 传输规范中。Streamable HTTP 结合 SSE 已经能覆盖流式场景，而且不需要 WebSocket 基础设施——后者在负载均衡和安全方面比普通 HTTP 更复杂。</p>

<h4 id="它们是包含关系吗">它们是包含关系吗？</h4>

<p><strong>不是包含关系，而是同层竞争关系。</strong> 三者都是解决”实时通信”问题的不同方案：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>实时通信技术
├── WebSocket（全双工，独立协议 ws://）
├── SSE（服务端单向推送，基于 HTTP）
└── 普通 HTTP 轮询（客户端主动查询）

MCP 传输层（选择上面其中一种或组合）
├── stdio（不涉及网络）
├── Streamable HTTP（= HTTP POST + 可选 SSE 流）
└── ~~HTTP+SSE~~（已弃用）
</code></pre></div></div>

<p>Streamable HTTP <strong>包含了 SSE 的使用</strong>，但它本身不等于 SSE，更不等于 WebSocket。</p>

<h3 id="四-核心通信协议json-rpc">四、 核心通信协议：JSON-RPC</h3>

<h4 id="1-通俗理解跨语言的普通话八股文">1. 通俗理解：跨语言的“普通话八股文”</h4>

<ul>
  <li><strong>RPC（Remote Procedure Call）</strong> 的核心思想是：让调用远程电脑上的一个函数，写起来就像调用自己电脑本地的函数一样简单。</li>
  <li><strong>JSON-RPC</strong> 是用 <strong>JSON 语法</strong> 包装的通信协议。它规定了大家说话必须带上固定的暗号字段（如版本号、函数名、参数、ID），解决了 AI（如 Python 编写）与工具（如 Go 编写）之间的语言壁垒。</li>
</ul>

<h4 id="2-普通-json-vs-json-rpc">2. 普通 JSON vs JSON-RPC</h4>

<ul>
  <li><strong>普通 JSON</strong> 是 <strong>数据交换格式（原材料）</strong>。它只管语法正确，里面写什么字段完全随心所欲。</li>
  <li><strong>JSON-RPC</strong> 是 <strong>行为协议（标准公文）</strong>。它借用了 JSON 的语法，但强制规定了内部字段。</li>
</ul>

<h5 id="格式对比">格式对比</h5>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">JSON</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"yonghu_ming"</span><span class="p">:</span><span class="w"> </span><span class="s2">"张三"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"nianling"</span><span class="p">:</span><span class="w"> </span><span class="mi">25</span><span class="w">
</span><span class="p">}</span><span class="w">

</span></code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">JSON-RPC</span><span class="w"> </span><span class="err">请求</span><span class="w">
</span><span class="p">{</span><span class="w">
    </span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"add"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"params"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">],</span><span class="w">
    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"msg-001"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><em>(注：<code class="language-plaintext highlighter-rouge">jsonrpc</code> 版本、<code class="language-plaintext highlighter-rouge">method</code> 函数名、<code class="language-plaintext highlighter-rouge">params</code> 参数、<code class="language-plaintext highlighter-rouge">id</code> 消息编号，每个字段都雷打不动)</em></p>

<h3 id="五-传输方式的实际交互模拟以查询北京天气为例">五、 传输方式的实际交互模拟（以“查询北京天气”为例）</h3>

<h4 id="1-stdio-模式交互本地进程间通信">1. stdio 模式交互（本地进程间通信）</h4>

<ul>
  <li>
    <p><strong>步骤 ①：LLM 进程向工具进程的 <code class="language-plaintext highlighter-rouge">stdin</code>（输入口）写入一行 JSON 字符串</strong></p>

    <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w"> </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tools/call"</span><span class="p">,</span><span class="w"> </span><span class="nl">"params"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"get_weather"</span><span class="p">,</span><span class="w"> </span><span class="nl">"arguments"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"city"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Beijing"</span><span class="p">}},</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">}</span><span class="w">
</span></code></pre></div>    </div>
  </li>
  <li>
    <p><strong>步骤 ②：工具进程本地查完天气，立刻向 <code class="language-plaintext highlighter-rouge">stdout</code>（输出口）吐出一行 JSON 字符串</strong>
```json
{“jsonrpc”: “2.0”, “result”: {“content”: [{“type”: “text”, “text”: “北京今天晴，25°C。”}]}, “id”: 1}</p>
  </li>
</ul>

<h4 id="2-旧版-http--sse-模式交互云端连接">2. 旧版 HTTP + SSE 模式交互（云端连接）</h4>

<ul>
  <li>
    <p><strong>步骤 ①：建立 SSE 长连接通道</strong> LLM 客户端请求：<code class="language-plaintext highlighter-rouge">GET /sse</code>。服务器响应并保持连接不断开，发回专属通道 ID：</p>

    <div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">event: endpoint
data: /message?sessionId=abc123xyz
</span></code></pre></div>    </div>
  </li>
  <li>
    <p><strong>步骤 ②：LLM 发送调用指令（普通 HTTP POST）</strong>
LLM 发送：<code class="language-plaintext highlighter-rouge">POST /message?sessionId=abc123xyz</code>，包体为：
```json
{“jsonrpc”: “2.0”, “method”: “tools/call”, “params”: {“name”: “get_weather”, “arguments”: {“city”: “Beijing”}}, “id”: 1}</p>
  </li>
</ul>

<p>服务器收到后回应 <code class="language-plaintext highlighter-rouge">202 Accepted</code>（表示正在处理）。</p>

<ul>
  <li>
    <p><strong>步骤 ③：工具通过步骤 ① 建立的 SSE 专线把结果“推”回来</strong></p>

    <div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">event: message
data: {"jsonrpc": "2.0", "result": {"content": [{"type": "text", "text": "北京今天晴，25°C。"}]}, "id": 1}
</span></code></pre></div>    </div>
  </li>
</ul>

<h4 id="3-新版-streamable-http-模式交互云端连接">3. 新版 Streamable HTTP 模式交互（云端连接）</h4>
<ul>
  <li>
    <p><strong>步骤 ①：LLM 直接发起标准的 HTTP POST 请求</strong>
直接发送到端点，无需提前握手建立长连接：
```json
{“jsonrpc”: “2.0”, “method”: “tools/call”, “params”: {“name”: “get_weather”, “arguments”: {“city”: “Beijing”}}, “id”: 1}</p>
  </li>
  <li>
    <p><strong>步骤 ②：工具直接流式返回响应</strong> 服务器响应头部设置为流式（如 <code class="language-plaintext highlighter-rouge">Transfer-Encoding: chunked</code>），在当前请求的响应中直接把 JSON 数据一段段吐出来：</p>

    <div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Transfer-Encoding</span><span class="p">:</span> <span class="s">chunked</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json-rpc+stream</span>

[第1段] {"jsonrpc": "2.0", "result": {"content": [{"type": "text",
[第2段] "text": "北京今天晴，25°C。"}]}, "id": 1}
</code></pre></div>    </div>

    <p><em>(特点：一次请求，直接搞定，既不常驻连接，又能实现流式传输)</em></p>
  </li>
</ul>

<hr />

<h3 id="六-关键思考既然-ai-需要完整结果流式的目的是什么">六、 关键思考：既然 AI 需要完整结果，流式的目的是什么？</h3>

<blockquote>
  <p>💡 <strong>核心疑问</strong>：对于工具调用来说，必须等到完整结果才能进行 LLM 下一轮思考和响应，为什么协议还要设计流式回复？</p>
</blockquote>

<h4 id="1-澄清一个误解stdio-也可以是流式的">1. 澄清一个误解：stdio 也可以是流式的</h4>
<p><code class="language-plaintext highlighter-rouge">stdio</code>、<code class="language-plaintext highlighter-rouge">Streamable HTTP</code> 只是<strong>修路的方式（通道）</strong>，而“流式还是阻塞”是<strong>车里装的货</strong>。<code class="language-plaintext highlighter-rouge">stdio</code> 通道完全可以通过连续写入多段 JSON-RPC Chunk 来实现流式传输。</p>

<h4 id="2-工具调用需要流式的核心原因">2. 工具调用需要“流式”的核心原因</h4>
<ol>
  <li><strong>进度条与用户体验</strong>：某些工具（如执行复杂代码、大数据分析、检索长篇文档）可能需要执行 30 秒。阻塞式会导致前端死卡，而流式可以允许工具实时吐出中间日志（如 <em>“正在扫描第 3 个数据库…”</em>），向用户展示任务进度。</li>
  <li><strong>海量数据传输（防止内存撑爆）</strong>：当 MCP 传输大文件资源（如 2GB 的日志文件或大型知识库内容）时，<strong>阻塞式会一次性把大字符串读入内存导致崩溃（OOM）</strong>。流式可以像“抽水机”一样读一行、发一行。</li>
  <li><strong>工具本身是子智能体（Sub-Agent）</strong>：在复杂 AI 工作流中，主 AI 调用的 MCP 工具本身可能就是另一个大模型（子智能体）。子智能体自身的思考过程就是一字一字蹦出来的（流式），主 AI 和用户需要实时看到它的思考链。</li>
</ol>

<hr />

<h3 id="七-websocket-深度对比与交互模拟mcp里没有websocket">七、 WebSocket 深度对比与交互模拟（MCP里没有websocket）</h3>

<blockquote>
  <p>MCP里没有websocket,这里是扩展，与http和sse对比学习</p>
</blockquote>

<h4 id="1-核心通信特征对比">1. 核心通信特征对比</h4>

<table>
  <thead>
    <tr>
      <th style="text-align: left">协议/方式</th>
      <th style="text-align: left">传输特点</th>
      <th style="text-align: left">形象比喻</th>
      <th style="text-align: left">适合场景</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>HTTP（普通）</strong></td>
      <td style="text-align: left">一问一答，用完就挂</td>
      <td style="text-align: left">戳一下，回一下，随后假装不认识</td>
      <td style="text-align: left">网页静态数据获取</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>SSE</strong></td>
      <td style="text-align: left">一次订阅，服务端单向推流</td>
      <td style="text-align: left">听收音机广播，只能听不能说</td>
      <td style="text-align: left">实时大屏、AI 文本流式回复</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>WebSocket</strong></td>
      <td style="text-align: left">全双工（双向同时）长连接</td>
      <td style="text-align: left">打双向网络电话，随时互相大喊大叫</td>
      <td style="text-align: left">多人联机游戏、实时聊天客服</td>
    </tr>
  </tbody>
</table>

<h4 id="2-websocket-完整交互模拟">2. WebSocket 完整交互模拟</h4>
<ul>
  <li>
    <p><strong>步骤 ①：握手升级（HTTP Handshake）</strong>
客户端发一个带有升级标记的 HTTP 请求：</p>

    <p>```http
GET /chat-ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==</p>
  </li>
  <li>
    <p><strong>步骤 ②：服务器同意升级</strong> 服务器回应 101 状态码，传统 HTTP 宣告结束，正式接通 WebSocket 电话线：</p>

    <p>HTTP</p>

    <div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">101</span> <span class="ne">Switching Protocols</span>
<span class="na">Upgrade</span><span class="p">:</span> <span class="s">websocket</span>
<span class="na">Connection</span><span class="p">:</span> <span class="s">Upgrade</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p><strong>步骤 ③：LLM 顺着电话线发送 JSON-RPC 数据帧</strong></p>

    <p>```json
{“jsonrpc”: “2.0”, “method”: “tools/call”, “params”: {“name”: “get_weather”, “arguments”: {“city”: “Beijing”}}, “id”: 1}</p>
  </li>
  <li>
    <p><strong>步骤 ④：工具顺着同一根线立刻丢回结果</strong></p>

    <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w"> </span><span class="nl">"result"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"北京今天晴，25°C。"</span><span class="p">}]},</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">}</span><span class="w">
</span></code></pre></div>    </div>
  </li>
  <li>
    <p><strong>步骤 ⑤：服务端主动推流（WebSocket 的独特威力）</strong>
连接未断开。2分钟后北京突然发布暴雨预警，工具<strong>无需等待 LLM 提问</strong>，直接主动把消息拍在 LLM 脸上：
```json
{“jsonrpc”: “2.0”, “method”: “notifications/weather_alert”, “params”: {“alert”: “北京发布暴雨蓝</p>
  </li>
</ul>

<h4 id="3-为什么-mcp-偏爱-streamable-http-胜过-websocket">3. 为什么 MCP 偏爱 Streamable HTTP 胜过 WebSocket？</h4>

<p>在 MCP 场景下，AI 调用工具往往是“AI 问（Method Call） -&gt; 工具答（Result Stream）”。</p>

<ul>
  <li><strong>WebSocket</strong> 的“双方随时可以主动说话”的超级能力，<strong>在 MCP 里大部分时间是浪费的。</strong></li>
  <li>相反，WebSocket 为了维持那根随时可以双向说话的“电话线”，需要耗费极高的服务器资源（无法轻易使用 Serverless 云函数），且更容易因为网络波动而断线。所以 MCP 最终选择走向了更轻量、更现代的 <strong>Streamable HTTP</strong>。</li>
</ul>]]></content><author><name>niuteng5618</name></author><category term="智能体应用开发" /><category term="Agent 工具链" /><category term="MCP" /><category term="MCP" /><category term="Streamable HTTP" /><category term="SSE" /><category term="stdio" /><category term="JSON-RPC" /><summary type="html"><![CDATA[🗂️ MCP 支持哪些传输协议？——通俗讲解 一、先理解一个比喻：快递是怎么送的？ MCP（Model Context Protocol）是 AI 和工具之间通信的”规则”，但光有规则还不够，还需要选择运输方式，就像快递可以用摩托车、货车、飞机来运，这在 MCP 里叫 Transport（传输层）。 目前 MCP 官方支持两种正式传输方式，另有一种已被弃用： 二、三种传输方式逐一解释 🟢 1. stdio（标准输入输出） 生活比喻： 就像两个人面对面用纸条传话。 客户端把 MCP Server 当作一个子进程启动，Server 从 stdin（标准输入）读取消息，从 stdout（标准输出）发送消息，消息格式是 JSON-RPC，每行一条。 ✅ 适合：本地运行，比如你在自己电脑上用 Claude Desktop 调用本地工具 ✅ 简单、零网络开销 ❌ 缺点：只能本地用，无法跨网络 🔴 2. HTTP+SSE（旧版，已弃用） 生活比喻： 你打了两部手机——一部专门接电话（收服务器消息），一部专门打出去（发请求给服务器），这很麻烦。 MCP 最初需要远程传输时（2024-11-05 规范），采用了 SSE 方案，使用两个端点：客户端通过 GET 连接到 /sse 端点来接收服务器的流式响应，然后通过 POST 发送请求到单独的 /messages 端点。 为什么被弃用？ 需要管理两个端点，服务器还要维护 SSE 连接 ID 和 POST 请求之间的映射关系，加重了有状态的复杂性，水平扩展很麻烦。另外很多负载均衡器、API 网关对长连接 SSE 的兼容性不好。 🟡 3. Streamable HTTP（新版，当前推荐） 生活比喻： 只用一个手机号，既能接电话又能打电话，服务器还可以选择”发短信”（普通 JSON 回复）或”打语音电话”（SSE 流式响应）。 Streamable HTTP 使用单一端点（如 /mcp），支持 POST 和 GET。客户端通过 POST 发送 JSON-RPC 消息；服务器可以选择回一个普通 JSON 响应，也可以升级为 SSE 流来处理耗时操作，不需要独立的”事件端点”。 关键理解： SSE 这项技术本身并没有消失，它作为可选的流式响应方式被内嵌在 Streamable HTTP 里面了。被弃用的是”旧版双端点 HTTP+SSE 传输方案”，而不是 SSE 技术本身。 三、SSE 被弃用？怎么理解？ 这是很多文章容易让人误解的地方，要区分两个层次： 层次 状态 SSE 技术本身（Server-Sent Events，服务器推送事件） ✅ 没有弃用，仍在使用 旧版 HTTP+SSE 传输方案（MCP 2024-11-05 规范里的那个双端点设计） ❌ 在 2025-03-26 规范中正式弃用 旧版 HTTP+SSE 传输在 2025-03-26 规范中被正式弃用，新实现应使用 Streamable HTTP。但服务器为了兼容老客户端，可以继续保留旧端点。 时间线： 2024-11-05，MCP 初始规范，定义了 stdio 和 SSE 作为两种标准传输；2025-03-26，Streamable HTTP 引入，SSE 正式弃用；2025-11-25 最新规范中，正式的两种标准传输为 stdio 和 Streamable HTTP，SSE 仅保留用于向后兼容。 一句话理解旧版HTTP+SSE和StreamableHTTP的差异 旧版 HTTP + SSE 采用尴尬的双端点架构，AI 必须在一个端点通过 HTTP POST 发送指令，再从另一个独立的 SSE 端点接收流式响应，导致服务器必须依靠 Session ID 维持死板的“有状态”连接。 新版 Streamable HTTP 彻底优化为单一标准 HTTP 端点，让 AI 的每一次工具调用都变成“同一个请求进去，流式响应直接出来”的无状态交互。这不仅斩断了 Session 的包袱，更彻底解决了旧版无法部署在云端 Serverless（无服务器）架构上、网络抖动易断线解绑的致命痛点。 四、它们和 WebSocket 的关系 SSE 和 WebSocket 的核心区别 SSE 是基于 HTTP 标准的单向通信技术，服务器可以向客户端推送数据，但客户端无法通过 SSE 反向发送数据；WebSocket 则是全双工协议，客户端和服务器可以同时互相发送接收消息，连接一旦建立就保持打开状态。 用生活比喻： SSE = 广播电台，只有电台（服务器）发声，听众（客户端）只能听 WebSocket = 对讲机，双方都可以随时说话 MCP 支持 WebSocket 吗？ 不支持。WebSocket 不在当前 MCP 传输规范中。Streamable HTTP 结合 SSE 已经能覆盖流式场景，而且不需要 WebSocket 基础设施——后者在负载均衡和安全方面比普通 HTTP 更复杂。 它们是包含关系吗？ 不是包含关系，而是同层竞争关系。 三者都是解决”实时通信”问题的不同方案： 实时通信技术 ├── WebSocket（全双工，独立协议 ws://） ├── SSE（服务端单向推送，基于 HTTP） └── 普通 HTTP 轮询（客户端主动查询） MCP 传输层（选择上面其中一种或组合） ├── stdio（不涉及网络） ├── Streamable HTTP（= HTTP POST + 可选 SSE 流） └── ~~HTTP+SSE~~（已弃用） Streamable HTTP 包含了 SSE 的使用，但它本身不等于 SSE，更不等于 WebSocket。 四、 核心通信协议：JSON-RPC 1. 通俗理解：跨语言的“普通话八股文” RPC（Remote Procedure Call） 的核心思想是：让调用远程电脑上的一个函数，写起来就像调用自己电脑本地的函数一样简单。 JSON-RPC 是用 JSON 语法 包装的通信协议。它规定了大家说话必须带上固定的暗号字段（如版本号、函数名、参数、ID），解决了 AI（如 Python 编写）与工具（如 Go 编写）之间的语言壁垒。 2. 普通 JSON vs JSON-RPC 普通 JSON 是 数据交换格式（原材料）。它只管语法正确，里面写什么字段完全随心所欲。 JSON-RPC 是 行为协议（标准公文）。它借用了 JSON 的语法，但强制规定了内部字段。 格式对比 JSON { "yonghu_ming": "张三", "nianling": 25 } JSON-RPC 请求 { "jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": "msg-001" } (注：jsonrpc 版本、method 函数名、params 参数、id 消息编号，每个字段都雷打不动) 五、 传输方式的实际交互模拟（以“查询北京天气”为例） 1. stdio 模式交互（本地进程间通信） 步骤 ①：LLM 进程向工具进程的 stdin（输入口）写入一行 JSON 字符串 {"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "get_weather", "arguments": {"city": "Beijing"}}, "id": 1} 步骤 ②：工具进程本地查完天气，立刻向 stdout（输出口）吐出一行 JSON 字符串 ```json {“jsonrpc”: “2.0”, “result”: {“content”: [{“type”: “text”, “text”: “北京今天晴，25°C。”}]}, “id”: 1} 2. 旧版 HTTP + SSE 模式交互（云端连接） 步骤 ①：建立 SSE 长连接通道 LLM 客户端请求：GET /sse。服务器响应并保持连接不断开，发回专属通道 ID： event: endpoint data: /message?sessionId=abc123xyz 步骤 ②：LLM 发送调用指令（普通 HTTP POST） LLM 发送：POST /message?sessionId=abc123xyz，包体为： ```json {“jsonrpc”: “2.0”, “method”: “tools/call”, “params”: {“name”: “get_weather”, “arguments”: {“city”: “Beijing”}}, “id”: 1} 服务器收到后回应 202 Accepted（表示正在处理）。 步骤 ③：工具通过步骤 ① 建立的 SSE 专线把结果“推”回来 event: message data: {"jsonrpc": "2.0", "result": {"content": [{"type": "text", "text": "北京今天晴，25°C。"}]}, "id": 1} 3. 新版 Streamable HTTP 模式交互（云端连接） 步骤 ①：LLM 直接发起标准的 HTTP POST 请求 直接发送到端点，无需提前握手建立长连接： ```json {“jsonrpc”: “2.0”, “method”: “tools/call”, “params”: {“name”: “get_weather”, “arguments”: {“city”: “Beijing”}}, “id”: 1} 步骤 ②：工具直接流式返回响应 服务器响应头部设置为流式（如 Transfer-Encoding: chunked），在当前请求的响应中直接把 JSON 数据一段段吐出来： HTTP/1.1 200 OK Transfer-Encoding: chunked Content-Type: application/json-rpc+stream [第1段] {"jsonrpc": "2.0", "result": {"content": [{"type": "text", [第2段] "text": "北京今天晴，25°C。"}]}, "id": 1} (特点：一次请求，直接搞定，既不常驻连接，又能实现流式传输) 六、 关键思考：既然 AI 需要完整结果，流式的目的是什么？ 💡 核心疑问：对于工具调用来说，必须等到完整结果才能进行 LLM 下一轮思考和响应，为什么协议还要设计流式回复？ 1. 澄清一个误解：stdio 也可以是流式的 stdio、Streamable HTTP 只是修路的方式（通道），而“流式还是阻塞”是车里装的货。stdio 通道完全可以通过连续写入多段 JSON-RPC Chunk 来实现流式传输。 2. 工具调用需要“流式”的核心原因 进度条与用户体验：某些工具（如执行复杂代码、大数据分析、检索长篇文档）可能需要执行 30 秒。阻塞式会导致前端死卡，而流式可以允许工具实时吐出中间日志（如 “正在扫描第 3 个数据库…”），向用户展示任务进度。 海量数据传输（防止内存撑爆）：当 MCP 传输大文件资源（如 2GB 的日志文件或大型知识库内容）时，阻塞式会一次性把大字符串读入内存导致崩溃（OOM）。流式可以像“抽水机”一样读一行、发一行。 工具本身是子智能体（Sub-Agent）：在复杂 AI 工作流中，主 AI 调用的 MCP 工具本身可能就是另一个大模型（子智能体）。子智能体自身的思考过程就是一字一字蹦出来的（流式），主 AI 和用户需要实时看到它的思考链。 七、 WebSocket 深度对比与交互模拟（MCP里没有websocket） MCP里没有websocket,这里是扩展，与http和sse对比学习 1. 核心通信特征对比 协议/方式 传输特点 形象比喻 适合场景 HTTP（普通） 一问一答，用完就挂 戳一下，回一下，随后假装不认识 网页静态数据获取 SSE 一次订阅，服务端单向推流 听收音机广播，只能听不能说 实时大屏、AI 文本流式回复 WebSocket 全双工（双向同时）长连接 打双向网络电话，随时互相大喊大叫 多人联机游戏、实时聊天客服 2. WebSocket 完整交互模拟 步骤 ①：握手升级（HTTP Handshake） 客户端发一个带有升级标记的 HTTP 请求： ```http GET /chat-ws HTTP/1.1 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== 步骤 ②：服务器同意升级 服务器回应 101 状态码，传统 HTTP 宣告结束，正式接通 WebSocket 电话线： HTTP HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade 步骤 ③：LLM 顺着电话线发送 JSON-RPC 数据帧 ```json {“jsonrpc”: “2.0”, “method”: “tools/call”, “params”: {“name”: “get_weather”, “arguments”: {“city”: “Beijing”}}, “id”: 1} 步骤 ④：工具顺着同一根线立刻丢回结果 {"jsonrpc": "2.0", "result": {"content": [{"type": "text", "text": "北京今天晴，25°C。"}]}, "id": 1} 步骤 ⑤：服务端主动推流（WebSocket 的独特威力） 连接未断开。2分钟后北京突然发布暴雨预警，工具无需等待 LLM 提问，直接主动把消息拍在 LLM 脸上： ```json {“jsonrpc”: “2.0”, “method”: “notifications/weather_alert”, “params”: {“alert”: “北京发布暴雨蓝 3. 为什么 MCP 偏爱 Streamable HTTP 胜过 WebSocket？ 在 MCP 场景下，AI 调用工具往往是“AI 问（Method Call） -&gt; 工具答（Result Stream）”。 WebSocket 的“双方随时可以主动说话”的超级能力，在 MCP 里大部分时间是浪费的。 相反，WebSocket 为了维持那根随时可以双向说话的“电话线”，需要耗费极高的服务器资源（无法轻易使用 Serverless 云函数），且更容易因为网络波动而断线。所以 MCP 最终选择走向了更轻量、更现代的 Streamable HTTP。]]></summary></entry><entry><title type="html">RAG 混合检索：BM25、Embedding 检索、RRF 与 Cross-Encoder Rerank</title><link href="https://niuteng5618.github.io/075-rag-hybrid-retrieval/" rel="alternate" type="text/html" title="RAG 混合检索：BM25、Embedding 检索、RRF 与 Cross-Encoder Rerank" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://niuteng5618.github.io/075-rag-hybrid-retrieval</id><content type="html" xml:base="https://niuteng5618.github.io/075-rag-hybrid-retrieval/"><![CDATA[<h1 id="rag-混合检索bm25embedding-检索rrf-与-cross-encoder-rerank">RAG 混合检索：BM25、Embedding 检索、RRF 与 Cross-Encoder Rerank</h1>

<p>在企业知识库、售后工单、代码文档和内部系统问答中，检索链路不能只依赖一种召回方式。<strong>BM25</strong> 擅长精确字面匹配，<strong>Embedding 向量检索</strong>擅长语义相似匹配，<strong>RRF</strong> 负责把不同召回通道的结果按排名融合，<strong>Cross-Encoder Rerank</strong> 负责对融合后的候选 Chunk 做最终精排。</p>

<p>这篇文档聚焦一个核心问题：<strong>BM25 和向量检索不是二选一，而是要按 Query 类型协同工作。</strong></p>

<p>对于 <code class="language-plaintext highlighter-rouge">error_code=E1234</code>、<code class="language-plaintext highlighter-rouge">CreateOrderV2</code>、<code class="language-plaintext highlighter-rouge">OPS-Sentinel</code>、<code class="language-plaintext highlighter-rouge">iPhone 15 Pro Max</code> 这类硬 token，关键词检索必须参与。</p>

<p>对于“用户支付失败后怎么排查”这类自然语言描述，Embedding 向量检索更有优势。生产级 RAG 通常会把两路召回合并，再通过 Rerank 控制最终进入 LLM 的证据质量。</p>

<hr />

<h2 id="1-检索方式的核心分类">1. 检索方式的核心分类</h2>

<h3 id="11-稀疏检索以-bm25-为代表的关键词召回">1.1 稀疏检索：以 BM25 为代表的关键词召回</h3>

<p><strong>稀疏检索（Sparse Retrieval）</strong>指基于词项、倒排索引和关键词权重进行召回的检索方式。它之所以叫“稀疏”，是因为系统会把文本映射到一个很大的词表空间中，而一段文本只会命中其中很少一部分词项，大多数位置都可以理解为 0。工程上通常不会真的保存一个巨大的稀疏数组，而是使用<strong>倒排索引</strong>记录“某个词出现在哪些文档或 Chunk 中”。</p>

<p>稀疏检索的代表技术是 <strong>BM25</strong>，下文会在 <a href="#2-bm25关键词精确匹配的主力">第 2 节：BM25：关键词精确匹配的主力</a> 中详细解释它的公式、分词方式、工作原理和适用场景。</p>

<p>它最适合处理错误码、订单号、型号、API 名、版本号、法规条款、配置项和企业内部专有名词。原因很直接：这些信息的<strong>字面形式就是身份</strong>，差一个字符就可能代表完全不同的对象。</p>

<p>举例来说，用户问 <code class="language-plaintext highlighter-rouge">E1234 支付失败怎么处理？</code>，BM25 会明确区分 <code class="language-plaintext highlighter-rouge">E1234</code> 和 <code class="language-plaintext highlighter-rouge">E1243</code>。只要文档中出现了 <code class="language-plaintext highlighter-rouge">E1234</code>，它就能通过倒排索引快速命中；如果文档只出现 <code class="language-plaintext highlighter-rouge">E1243</code>，即使两个字符串看起来很像，也不会因为“长得像”而被当成同一个错误码。</p>

<h3 id="12-稠密检索以-embedding-向量检索为代表的语义召回">1.2 稠密检索：以 Embedding 向量检索为代表的语义召回</h3>

<p><strong>稠密检索（Dense Retrieval）</strong>指基于 Embedding 向量进行语义相似度召回的检索方式。它之所以叫“稠密”，是因为一段文本会被模型编码成一个固定维度的连续向量，向量中的大多数维度都有数值，而不是像稀疏表示那样大面积为 0。</p>

<p>稠密检索的代表技术是 <strong>Embedding 向量检索</strong>，下文会在 <a href="#3-embedding-向量检索语义召回的主力">第 3 节：Embedding 向量检索：语义召回的主力</a> 中详细解释。</p>

<p>它最适合处理自然语言问题、长文本描述、同义改写和症状描述。例如“用户付款后订单没生成”和“支付成功但订单创建失败”字面不完全一致，但语义上高度相关，向量检索通常能把它们匹配到一起。</p>

<p>稠密检索的优势是语义泛化，缺点是对低频专有名词和硬 token 不够稳定。通用 Embedding 模型可能不知道企业内部的 <code class="language-plaintext highlighter-rouge">飞翼平台</code>、<code class="language-plaintext highlighter-rouge">OPS-Sentinel</code>、<code class="language-plaintext highlighter-rouge">CreateOrderV2</code> 分别代表什么，也不会天然保证 <code class="language-plaintext highlighter-rouge">E1234</code> 必须匹配包含 <code class="language-plaintext highlighter-rouge">E1234</code> 的文档。</p>

<h3 id="13-混合检索让关键词和语义各自发挥优势">1.3 混合检索：让关键词和语义各自发挥优势</h3>

<p><strong>混合检索（Hybrid Search）</strong>不是简单地把 BM25 分数和向量分数相加，而是让两类检索信号在同一条链路中互补。BM25 负责保证精确 token 不漏召回，Embedding 向量检索负责补充语义相关候选，RRF 负责对不同通道的排名进行融合，Cross-Encoder Rerank 再对候选 Chunk 做精排。</p>

<p>一个典型的混合检索链路可以概括为：用户 Query 进入 Query Router，系统判断它更偏硬 token 还是自然语言语义，然后分别请求 BM25 索引和向量索引得到候选 Chunk；候选 Chunk 经过 RRF 融合后形成统一候选池，再送入 Cross-Encoder Rerank 重新排序，最终只取少量高相关 Chunk 进入 LLM Prompt。</p>

<hr />

<h2 id="2-bm25关键词精确匹配的主力">2. BM25：关键词精确匹配的主力</h2>

<p>BM25 是经典的关键词检索算法。它不是简单统计“某个词出现了几次”，而是同时考虑<strong>词频</strong>、<strong>逆文档频率</strong>和<strong>文档长度归一化</strong>。</p>

<p><strong>词频</strong>表示查询词在文档中出现的次数。<strong>逆文档频率</strong>表示这个词在整个语料中有多稀有，越稀有越有区分度。<strong>文档长度归一化</strong>用于避免长文档因为词多而天然获得更高分。</p>

<p>在真正计算 BM25 之前，系统会先做<strong>分词</strong>。分词的目标是把 Query 和 Chunk 都切成可统计的词项，也就是 BM25 公式里的 <code class="language-plaintext highlighter-rouge">q_i</code>。</p>

<p>中文场景下，分词通常依赖 Elasticsearch IK、jieba、HanLP、Lucene SmartCN 或自定义词典。英文场景下，分词通常会按空格、标点、大小写规范化、词干还原或词形归一化处理。</p>

<p>例如用户 Query 是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>E1234 支付失败怎么处理？
</code></pre></div></div>

<p>一个适合售后场景的分词结果可能是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[E1234, 支付失败, 支付, 失败, 处理]
</code></pre></div></div>

<p>这里最关键的是 <code class="language-plaintext highlighter-rouge">E1234</code> 不能被错误切碎，也不能被当成普通噪声词丢掉。对于错误码、订单号、API 名、配置项、版本号这类硬 token，生产系统通常会用正则和领域词典保护它们，让它们作为完整词项进入 BM25。</p>

<p>如果分词器把 <code class="language-plaintext highlighter-rouge">CreateOrderV2</code> 切成 <code class="language-plaintext highlighter-rouge">Create</code>、<code class="language-plaintext highlighter-rouge">Order</code>、<code class="language-plaintext highlighter-rouge">V2</code>，或者把 <code class="language-plaintext highlighter-rouge">OPS-Sentinel</code> 切丢了 <code class="language-plaintext highlighter-rouge">Sentinel</code>，BM25 后面的公式再正确也会召回不稳。因此 BM25 的工程效果不只取决于公式，也取决于<strong>分词器、停用词表、同义词表和领域词典</strong>。</p>

<p>BM25 的常见公式如下：</p>

\[score(D,Q)=\sum_{q_i \in Q} IDF(q_i) \cdot \frac{f(q_i,D)\cdot(k_1+1)}{f(q_i,D)+k_1\cdot(1-b+b\cdot\frac{|D|}{avgdl})}\]

<p>其中，<code class="language-plaintext highlighter-rouge">D</code> 表示文档或 Chunk，<code class="language-plaintext highlighter-rouge">Q</code> 表示用户 Query，<code class="language-plaintext highlighter-rouge">q_i</code> 表示 Query 分词后的某个词项。</p>

<p><code class="language-plaintext highlighter-rouge">f(q_i,D)</code> 表示这个词项在文档中出现的次数，<code class="language-plaintext highlighter-rouge">IDF(q_i)</code> 表示这个词项的稀有程度，<code class="language-plaintext highlighter-rouge">|D|</code> 表示当前文档长度，<code class="language-plaintext highlighter-rouge">avgdl</code> 表示语料库中文档的平均长度。</p>

<p><code class="language-plaintext highlighter-rouge">k1</code> 控制词频增长的饱和速度，<code class="language-plaintext highlighter-rouge">b</code> 控制文档长度归一化强度。</p>

<p>从直觉上看，BM25 做了三件事：Query 里的词在文档里出现，文档就应该加分；一个词如果在全库里很少见，它比“的、是、怎么”这类常见词更重要；长文档不能因为内容多、碰巧命中更多词就无条件占优势。</p>

<p>下面用一个售后工单场景说明。用户 Query 是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>E1234 支付失败怎么处理？
</code></pre></div></div>

<p>候选 Chunk 如下：</p>

<table>
  <thead>
    <tr>
      <th>Chunk</th>
      <th>内容</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A</td>
      <td>错误码 E1234 表示支付渠道超时，请检查第三方支付网关，并按重复扣款流程核验。</td>
    </tr>
    <tr>
      <td>B</td>
      <td>错误码 E1243 表示库存锁定失败，请检查库存服务和库存回滚日志。</td>
    </tr>
    <tr>
      <td>C</td>
      <td>支付失败可能由网络异常、余额不足、渠道超时或风控拒绝导致。</td>
    </tr>
  </tbody>
</table>

<p>BM25 会认为 Chunk A 同时命中 <code class="language-plaintext highlighter-rouge">E1234</code>、<code class="language-plaintext highlighter-rouge">支付失败</code> 和 <code class="language-plaintext highlighter-rouge">处理</code> 相关词，其中 <code class="language-plaintext highlighter-rouge">E1234</code> 由于在全库中非常稀有，会带来很强的区分度。Chunk C 虽然语义上和支付失败有关，但没有命中 <code class="language-plaintext highlighter-rouge">E1234</code>；Chunk B 虽然同样是错误码文档，却命中的是 <code class="language-plaintext highlighter-rouge">E1243</code>，不能替代 <code class="language-plaintext highlighter-rouge">E1234</code>。因此在这类 Query 中，BM25 的精确匹配优势非常明显。</p>

<p>BM25 特别适合错误码、订单号、产品型号、API 名、函数名、版本号、法规条款、SKU、配置项和内部系统代号。它的工程优势也很明确：不需要训练数据，不需要 GPU，基于 Elasticsearch、OpenSearch、Lucene 等系统即可落地；命中哪个词、贡献了多少分也更容易通过日志解释。</p>

<hr />

<h2 id="3-embedding-向量检索语义召回的主力">3. Embedding 向量检索：语义召回的主力</h2>

<p>Embedding 向量检索会先用 Embedding 模型把 Query 和 Chunk 分别编码成向量，再计算向量之间的相似度。语义越接近的文本，向量空间中的距离通常越近。常见相似度计算方式包括<strong>余弦相似度</strong>、<strong>点积</strong>和<strong>欧氏距离</strong>，实际使用哪一种取决于模型训练方式和向量数据库配置。</p>

<p>以自然语言问题为例，用户 Query 是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>用户付款后订单没生成，应该怎么排查？
</code></pre></div></div>

<p>知识库中可能没有完全相同的句子，但有一个 Chunk 写着：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>支付成功但订单创建失败时，需要依次检查支付回调、订单服务幂等表和消息队列消费状态。
</code></pre></div></div>

<p>BM25 可能只命中“支付”或“订单”等普通词，排序不一定靠前；Embedding 向量检索则能理解“付款后订单没生成”和“支付成功但订单创建失败”语义接近，因此更容易召回正确文档。</p>

<p>但对 <code class="language-plaintext highlighter-rouge">E1234</code>、<code class="language-plaintext highlighter-rouge">E1243</code>、<code class="language-plaintext highlighter-rouge">CreateOrderV2</code> 这类字符串，Embedding 模型的可靠性会下降。原因可以合并理解为：这类字符串语义信息很少，通用训练语料覆盖不足，分词结果又可能被切成多个子 token；而向量相似度本质上是软匹配，不会天然保证“必须包含这个精确字符串”。因此 <code class="language-plaintext highlighter-rouge">E1234</code> 和 <code class="language-plaintext highlighter-rouge">E1243</code> 在业务上完全不同，但在纯向量空间中可能被认为形式相近，导致召回错误 Chunk。</p>

<p>对企业内部低频专有名词也类似。<code class="language-plaintext highlighter-rouge">飞翼平台</code>、<code class="language-plaintext highlighter-rouge">OPS-Sentinel</code>、<code class="language-plaintext highlighter-rouge">星河工单系统</code> 这类词对企业内部人员很明确，但通用 Embedding 模型未必学过它们的业务含义。用户问“飞翼平台怎么申请权限”，纯向量检索可能召回其他平台的权限文档；BM25 则能直接锁定包含“飞翼平台”的 Chunk。所以在企业 RAG 中，Embedding 检索负责语义泛化，BM25 负责精确约束，两者缺一不可。</p>

<hr />

<h2 id="4-hybrid-融合query-routerrrf-与动态权重">4. Hybrid 融合：Query Router、RRF 与动态权重</h2>

<p>Hybrid 的第一步不是融合分数，而是判断 Query 类型。<strong>Query Router</strong> 是一个查询路由模块，用来判断当前 Query 更依赖关键词还是语义。</p>

<p>Query Router 不一定要用大模型。生产系统里更常见的做法是先用规则、正则表达式、领域词典和少量统计特征实现一个轻量 Router。只有当 Query 很复杂、规则无法判断时，才考虑引入小模型或 LLM 做二级分类。</p>

<h3 id="41-query-router-如何实现">4.1 Query Router 如何实现</h3>

<p>Query Router 的输入通常是原始 Query、用户所属业务线、知识库 ID、会话上下文和权限过滤条件。它的输出不是最终答案，而是一份<strong>检索策略</strong>，例如走 BM25 还是向量检索、两路分别取多少 Top-K、是否需要 Query Rewrite、是否要保护精确 token。</p>

<p>这部分不需要做得特别复杂。工程上可以先用规则实现一个稳定版本，再根据 bad case 决定是否引入 LLM 分类。</p>

<pre><code class="language-mermaid">flowchart TD
    A[用户 Query] --&gt; B[预处理]
    B --&gt; C[识别硬 token / 领域词 / 意图]
    C --&gt; D{是否需要改写?}
    D --&gt;|是| E[Query Rewrite / Query Expansion]
    D --&gt;|否| F[Query 分类]
    E --&gt; F
    F --&gt; G{Query 类型}
    G --&gt;|精确匹配型| H[偏 BM25]
    G --&gt;|语义描述型| I[偏向量检索]
    G --&gt;|混合型| J[BM25 + 向量并行]
    G --&gt;|不确定| K[默认 Hybrid]
    H --&gt; L[输出检索策略]
    I --&gt; L
    J --&gt; L
    K --&gt; L
</code></pre>

<p>一个简单 Router 可以按下面流程实现。</p>

<p><strong>第一步：预处理 Query。</strong> 这一步做大小写归一化、全角半角转换、空格清洗和无意义标点清理，但不能破坏关键符号。<code class="language-plaintext highlighter-rouge">E1234</code>、<code class="language-plaintext highlighter-rouge">ORD-20260503-7788</code>、<code class="language-plaintext highlighter-rouge">CreateOrderV2</code>、<code class="language-plaintext highlighter-rouge">max_retry_count</code> 里的大小写、连字符和下划线都应该保留。</p>

<p><strong>第二步：识别强规则信号。</strong> 如果 Query 命中错误码、订单号、版本号、SKU、API 名、配置项、文件路径、URL、法规条款编号，就可以认为它有强关键词约束。这类 Query 必须让 BM25 参与。</p>

<p><strong>第三步：识别语义信号。</strong> 如果 Query 是完整自然语言问题，包含“为什么、怎么、如何、怎么排查、有什么区别”等问法，或者是一整段工单描述，就说明它需要语义召回。这类 Query 必须让向量检索参与。</p>

<p><strong>第四步：判断是否需要 Query Rewrite。</strong> 如果 Query 太口语化、指代不清或依赖上下文，例如“这个报错怎么处理”，就应该结合会话上下文改写成更完整的 Query。如果 Query 很短但同义表达很多，例如“重复扣款”，可以做 Query Expansion，补充“重复支付、重复扣费、支付流水核验、退款工单”等同义词或相关术语。</p>

<p><strong>第五步：输出检索策略。</strong> Router 最终只需要给出可执行策略，不需要给出答案。策略可以包括 <code class="language-plaintext highlighter-rouge">bm25_top_k</code>、<code class="language-plaintext highlighter-rouge">vector_top_k</code>、是否使用 RRF、是否进入 Rerank、是否保护某些 token。</p>

<p>关于 <code class="language-plaintext highlighter-rouge">lexical_score</code> 和 <code class="language-plaintext highlighter-rouge">semantic_score</code>，它们不是必须存在的模型分数，也不是让 LLM 随便打一个分。更常见的做法是规则加权，便于调试。</p>

<p><strong>lexical_score 可以理解为“字面精确匹配分”。</strong> 它衡量当前 Query 有多依赖关键词、编号、代码、错误码、产品型号、字段名、法规条款这类精确字面信息。这个分数越高，越说明 BM25 不能缺席。</p>

<p><strong>semantic_score 可以理解为“语义理解分”。</strong> 它衡量当前 Query 有多依赖自然语言理解、同义表达、问题意图、上下文描述和推理型检索。这个分数越高，越说明向量检索不能缺席。</p>

<p>规则加权可以从简单版本开始。比如命中一个错误码，<code class="language-plaintext highlighter-rouge">lexical_score += 3</code>；命中订单号、SKU、版本号、法规条款，<code class="language-plaintext highlighter-rouge">lexical_score += 3</code>；命中 API 名、函数名、配置项，<code class="language-plaintext highlighter-rouge">lexical_score += 2</code>；命中领域词典里的内部系统名或产品名，<code class="language-plaintext highlighter-rouge">lexical_score += 2</code>。</p>

<p>语义侧也可以类似处理。如果 Query 是完整问句，<code class="language-plaintext highlighter-rouge">semantic_score += 2</code>；如果包含“怎么排查、为什么、如何处理、有什么区别、能不能”这类意图词，<code class="language-plaintext highlighter-rouge">semantic_score += 2</code>；如果 Query 是一段较长的工单描述，<code class="language-plaintext highlighter-rouge">semantic_score += 2</code>；如果命中了同义词扩展词表，<code class="language-plaintext highlighter-rouge">semantic_score += 1</code>。</p>

<p>一个简单判断策略如下：</p>

<table>
  <thead>
    <tr>
      <th>判断条件</th>
      <th>路由结果</th>
      <th>示例</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">lexical_score &gt;= 3</code> 且 <code class="language-plaintext highlighter-rouge">semantic_score &lt; 2</code></td>
      <td>偏 BM25</td>
      <td><code class="language-plaintext highlighter-rouge">E1234 是什么意思？</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">semantic_score &gt;= 3</code> 且 <code class="language-plaintext highlighter-rouge">lexical_score &lt; 2</code></td>
      <td>偏向量检索</td>
      <td><code class="language-plaintext highlighter-rouge">用户付款后订单没生成怎么排查？</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">lexical_score &gt;= 3</code> 且 <code class="language-plaintext highlighter-rouge">semantic_score &gt;= 2</code></td>
      <td>强 Hybrid</td>
      <td><code class="language-plaintext highlighter-rouge">E1234 支付失败后重复扣款怎么办？</code></td>
    </tr>
    <tr>
      <td>两个分数都低</td>
      <td>默认 Hybrid</td>
      <td><code class="language-plaintext highlighter-rouge">退款失败</code></td>
    </tr>
    <tr>
      <td>两个分数接近但 Query 很复杂</td>
      <td>LLM 兜底分类</td>
      <td><code class="language-plaintext highlighter-rouge">客户说昨天付了两次钱但订单状态还是失败，这种算什么问题？</code></td>
    </tr>
  </tbody>
</table>

<p>这些阈值不是标准答案，而是初始配置。上线后应该根据 bad case 调整，例如错误码漏召回多，就降低 BM25 触发门槛；自然语言问题召回不准，就提高向量侧参与度。</p>

<p>当然，也可以直接用 LLM 做 Query 分类。LLM Router 的优点是能理解复杂语义、多意图问题和上下文省略，缺点是成本更高、延迟更高、结果稳定性不如规则，并且需要对输出做 JSON schema 约束。</p>

<p>更稳的做法是<strong>规则优先，LLM 兜底</strong>。以下情况适合触发 LLM 兜底：规则分数接近、Query 同时包含多个意图、Query 依赖上下文指代、用户输入是一整段工单描述、Query 需要先改写才能检索，或者规则命中了很多信号但互相冲突。</p>

<p>LLM 兜底不是让模型直接回答问题，而是让模型只做<strong>分类和改写</strong>。它应该输出固定 JSON，字段包括 Query 类型、是否需要改写、改写后的 Query、需要保护的硬 token、BM25 Top-K、Vector Top-K、是否使用 RRF、是否使用 Rerank。</p>

<p>LLM 兜底还要有安全策略。只要 Query 中存在错误码、订单号、API 名、版本号等硬 token，即使 LLM 判断为语义型，也不能关闭 BM25；最多只能调整两路 Top-K。LLM 不能删除硬 token，不能把 <code class="language-plaintext highlighter-rouge">E1234</code> 改写成 <code class="language-plaintext highlighter-rouge">E1243</code>，也不能凭空扩展不存在的业务实体。</p>

<p>LLM 分类可以要求输出固定 JSON，例如：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"query_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hybrid"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"need_rewrite"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"rewrite_query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"E1234 支付渠道超时导致重复扣款时如何处理？"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"route"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"bm25_top_k"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vector_top_k"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w">
    </span><span class="nl">"use_rrf"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"use_rerank"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"preserve_tokens"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"E1234"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>不同情况可以按下面方式处理：</p>

<table>
  <thead>
    <tr>
      <th>Query 类型</th>
      <th>示例</th>
      <th>推荐处理</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>精确匹配型</td>
      <td><code class="language-plaintext highlighter-rouge">E1234 是什么意思？</code></td>
      <td>偏 BM25，保护 <code class="language-plaintext highlighter-rouge">E1234</code>，向量少量补充</td>
    </tr>
    <tr>
      <td>语义描述型</td>
      <td><code class="language-plaintext highlighter-rouge">用户付款后订单没生成怎么排查？</code></td>
      <td>偏向量检索，BM25 少量补充关键词候选</td>
    </tr>
    <tr>
      <td>混合型</td>
      <td><code class="language-plaintext highlighter-rouge">E1234 支付失败后重复扣款怎么办？</code></td>
      <td>BM25 和向量都取较大 Top-K，再 RRF + Rerank</td>
    </tr>
    <tr>
      <td>代码/API 型</td>
      <td><code class="language-plaintext highlighter-rouge">CreateOrderV2 timeout 怎么处理？</code></td>
      <td>偏 BM25，保护 API 名，同时召回语义排查文档</td>
    </tr>
    <tr>
      <td>上下文省略型</td>
      <td><code class="language-plaintext highlighter-rouge">这个报错怎么处理？</code></td>
      <td>先 Query Rewrite，再 Hybrid 检索</td>
    </tr>
    <tr>
      <td>同义词扩展型</td>
      <td><code class="language-plaintext highlighter-rouge">重复扣款</code></td>
      <td>Query Expansion 后再 Hybrid 检索</td>
    </tr>
    <tr>
      <td>不确定型</td>
      <td><code class="language-plaintext highlighter-rouge">退款失败</code></td>
      <td>默认 Hybrid，后续依赖 RRF 和 Rerank 兜底</td>
    </tr>
  </tbody>
</table>

<p>Router 的目标不是一次分类永远正确，而是避免明显错误。错误码类 Query 不能被纯向量检索带偏，自然语言问题也不能只靠关键词硬匹配。只要 Router 能把大部分 Query 分到合理通道，后面的 RRF 和 Rerank 就会轻松很多。</p>

<h3 id="42-为什么不能直接相加分数">4.2 为什么不能直接相加分数</h3>

<p>常见错误写法是：</p>

\[final\_score=0.5\times bm25\_score+0.5\times vector\_score\]

<p>这个公式的问题在于，BM25 分数和向量相似度不是同一种度量。BM25 分数没有固定上限，会随着词频、IDF、文档长度和语料分布变化；余弦相似度通常落在较窄区间，例如 0.7 到 0.95。直接相加时，BM25 数值可能天然压过向量分数，导致语义检索结果几乎不参与排序。</p>

<p>举例来说，Chunk A 的 BM25 分数是 18.0，向量相似度是 0.60；Chunk B 的 BM25 分数是 2.0，向量相似度是 0.92。直接按 0.5 加权后，A 的融合分是 9.30，B 的融合分是 1.46。虽然 B 在语义上更匹配，但因为 BM25 原始数值更大，最终被压下去了。</p>

<p>归一化加权并不是不能用，但要谨慎。Min-Max 归一化和 Z-score 标准化可以把分数映射到相近范围，但它们会受到异常值、Query 分布变化和语料变化影响。更稳妥的起步方式是先用 RRF，只看每个通道内部的排名，不直接比较不同通道的原始分数。</p>

<h3 id="43-rrf-的原理和完整排名示例">4.3 RRF 的原理和完整排名示例</h3>

<p><strong>RRF（Reciprocal Rank Fusion，倒数排名融合）</strong>是一种按排名融合多路检索结果的方法。它不关心 BM25 分数是多少，也不关心向量相似度是多少，只关心某个 Chunk 在每一路检索结果中排第几。排名越靠前，贡献越大；同一个 Chunk 如果在多路检索中都靠前，总分会自然升高。</p>

<p>RRF 的标准公式如下：</p>

\[RRF(d)=\sum_{i=1}^{n}\frac{1}{c+rank_i(d)}\]

<p>其中，<code class="language-plaintext highlighter-rouge">d</code> 表示候选 Chunk，<code class="language-plaintext highlighter-rouge">n</code> 表示检索通道数量，<code class="language-plaintext highlighter-rouge">rank_i(d)</code> 表示 Chunk <code class="language-plaintext highlighter-rouge">d</code> 在第 <code class="language-plaintext highlighter-rouge">i</code> 个检索通道中的排名，<code class="language-plaintext highlighter-rouge">c</code> 是平滑常数，常见取值为 60。如果某个 Chunk 没有出现在某一路结果中，这一路通常不给它贡献分数。</p>

<p>假设用户 Query 是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>E1234 支付失败后用户被重复扣款怎么办？
</code></pre></div></div>

<p>BM25 和向量检索分别返回 Top-5：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">BM25 排名</th>
      <th>Chunk</th>
      <th>内容摘要</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td>A</td>
      <td>E1234 错误码处理说明</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>B</td>
      <td>支付失败错误码总览</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>C</td>
      <td>支付渠道超时排查</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td>D</td>
      <td>库存错误码说明</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td>E</td>
      <td>售后工单字段解释</td>
    </tr>
  </tbody>
</table>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Vector 排名</th>
      <th>Chunk</th>
      <th>内容摘要</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td>C</td>
      <td>支付渠道超时排查</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>F</td>
      <td>用户支付失败后的处理流程</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>B</td>
      <td>支付失败错误码总览</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td>A</td>
      <td>E1234 错误码处理说明</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td>G</td>
      <td>第三方支付网关异常说明</td>
    </tr>
  </tbody>
</table>

<p>取 <code class="language-plaintext highlighter-rouge">c = 60</code>，分别计算每个 Chunk 的 RRF 分数：</p>

<table>
  <thead>
    <tr>
      <th>Chunk</th>
      <th style="text-align: right">BM25 贡献</th>
      <th style="text-align: right">Vector 贡献</th>
      <th style="text-align: right">RRF 总分</th>
      <th>解释</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A</td>
      <td style="text-align: right">1 / (60 + 1) = 0.01639</td>
      <td style="text-align: right">1 / (60 + 4) = 0.01563</td>
      <td style="text-align: right">0.03202</td>
      <td>BM25 第一，向量也召回</td>
    </tr>
    <tr>
      <td>B</td>
      <td style="text-align: right">1 / (60 + 2) = 0.01613</td>
      <td style="text-align: right">1 / (60 + 3) = 0.01587</td>
      <td style="text-align: right">0.03200</td>
      <td>两路都比较靠前</td>
    </tr>
    <tr>
      <td>C</td>
      <td style="text-align: right">1 / (60 + 3) = 0.01587</td>
      <td style="text-align: right">1 / (60 + 1) = 0.01639</td>
      <td style="text-align: right">0.03226</td>
      <td>向量第一，BM25 也靠前</td>
    </tr>
    <tr>
      <td>D</td>
      <td style="text-align: right">1 / (60 + 4) = 0.01563</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0.01563</td>
      <td>只在 BM25 出现</td>
    </tr>
    <tr>
      <td>E</td>
      <td style="text-align: right">1 / (60 + 5) = 0.01538</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0.01538</td>
      <td>只在 BM25 出现</td>
    </tr>
    <tr>
      <td>F</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">1 / (60 + 2) = 0.01613</td>
      <td style="text-align: right">0.01613</td>
      <td>只在向量结果中靠前</td>
    </tr>
    <tr>
      <td>G</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">1 / (60 + 5) = 0.01538</td>
      <td style="text-align: right">0.01538</td>
      <td>只在向量结果中出现</td>
    </tr>
  </tbody>
</table>

<p>最终按 RRF 总分从高到低排序，得到融合排名：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">最终排名</th>
      <th>Chunk</th>
      <th style="text-align: right">RRF 总分</th>
      <th>为什么排在这里</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td>C</td>
      <td style="text-align: right">0.03226</td>
      <td>向量第一，BM25 第三，两路共同认可</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>A</td>
      <td style="text-align: right">0.03202</td>
      <td>BM25 第一，且向量也召回，硬 token 证据强</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>B</td>
      <td style="text-align: right">0.03200</td>
      <td>两路都靠前，但都不是第一</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td>F</td>
      <td style="text-align: right">0.01613</td>
      <td>只在向量中出现，但向量排名第二</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td>D</td>
      <td style="text-align: right">0.01563</td>
      <td>只在 BM25 中出现，排名第四</td>
    </tr>
    <tr>
      <td style="text-align: right">6</td>
      <td>E / G</td>
      <td style="text-align: right">0.01538</td>
      <td>单路靠后，分数相同可再按单路分数或稳定排序规则打破平局</td>
    </tr>
  </tbody>
</table>

<p>这个例子说明了 RRF 如何得到最终排名：先把每一路检索结果中的名次转成倒数贡献，再把同一个 Chunk 在不同通道中的贡献相加，最后按总分排序。它天然解决了 BM25 分数和向量分数尺度不一致的问题，也保留了“单路非常靠前”的候选资格。</p>

<hr />

<h2 id="5-rerank-与-cross-encoder关系原理结构和调用格式">5. Rerank 与 Cross-Encoder：关系、原理、结构和调用格式</h2>

<p><strong>Rerank（重排序、精排）</strong>是一道排序阶段，不是某一种固定模型。它的输入是一批已经召回的候选 Chunk，输出是这些候选 Chunk 针对当前 Query 的相关性排序。<strong>Cross-Encoder（交叉编码器）</strong>是实现 Rerank 的常见模型结构之一，也是 RAG 系统中最常见的精排方案之一。简单说，Rerank 是任务，Cross-Encoder 是经常用来完成这个任务的模型类型。</p>

<p>RRF 解决的是多路召回候选如何合并，Cross-Encoder Rerank 解决的是候选中哪几条真正适合放进 Prompt。RRF 只看排名，不理解 Query 和 Chunk 的逐 token 交互；Cross-Encoder 会把 Query 和 Chunk 放在一起输入模型，让模型直接判断二者是否相关，因此判断更细，但计算成本也更高。</p>

<h3 id="51-cross-encoder-的工作原理">5.1 Cross-Encoder 的工作原理</h3>

<p>Cross-Encoder 的典型输入不是单独的 Query 向量或单独的文档向量，而是一个 Query-Chunk 对。模型会把二者拼接成一个序列，例如：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[CLS] E1234 支付失败后用户被重复扣款怎么办？ [SEP] E1234 表示支付渠道超时，若发生重复扣款，请按照退款工单流程处理。 [SEP]
</code></pre></div></div>

<p>然后模型通过 Transformer 层让 Query 中的 token 和 Chunk 中的 token 充分交互。最后，模型通常取 <code class="language-plaintext highlighter-rouge">[CLS]</code> 位置的表示，接一个线性层或分类头，输出一个相关性分数。这个分数可以理解为“当前 Chunk 对当前 Query 有多相关”。</p>

<p>它和向量检索的差异非常关键。向量检索通常是 Bi-Encoder 架构，Query 和 Chunk 分别编码，提前把 Chunk 向量存进向量库，线上只需要编码 Query 并做近邻搜索，因此速度快、适合大规模召回。Cross-Encoder 则必须对每个 Query-Chunk 对重新计算，无法提前把所有 Chunk 的最终相关性算好，因此速度慢，但排序更准。</p>

<h3 id="52-cross-encoder-的模型结构">5.2 Cross-Encoder 的模型结构</h3>

<p>Cross-Encoder 的结构可以抽象为：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Query + Chunk
  ↓
Tokenizer 拼接为一个输入序列
  ↓
Transformer Encoder 编码
  ↓
取 [CLS] 或池化后的整体表示
  ↓
线性层 / 分类头输出相关性分数
  ↓
按分数从高到低排序
</code></pre></div></div>

<p>如果用更数学化的方式表示，可以写成：</p>

\[score(q,d)=Head(Encoder([q;d]))\]

<p>其中，<code class="language-plaintext highlighter-rouge">q</code> 表示 Query，<code class="language-plaintext highlighter-rouge">d</code> 表示候选 Chunk，<code class="language-plaintext highlighter-rouge">[q;d]</code> 表示把 Query 和 Chunk 拼接后输入模型，<code class="language-plaintext highlighter-rouge">Encoder</code> 通常是 BERT、RoBERTa、DeBERTa 或其他 Transformer Encoder，<code class="language-plaintext highlighter-rouge">Head</code> 是输出相关性分数的分类头或回归头。</p>

<p>Cross-Encoder 的输入长度通常有限制，例如 512、1024 或 2048 tokens。如果 Chunk 太长，需要在召回阶段就控制 Chunk 长度，或者在 Rerank 前进行截断。Rerank 的候选数量也不能太大，常见做法是先用 BM25 和向量检索召回几十到几百条，再用 Cross-Encoder 精排 Top-50 或 Top-100，最后取 Top-3 到 Top-5 进入 LLM。</p>

<hr />

<h2 id="6-生产级-rag-检索链路从-query-到-llm-的完整数据流">6. 生产级 RAG 检索链路：从 Query 到 LLM 的完整数据流</h2>

<p>生产级链路需要同时描述离线入库和在线查询。离线阶段负责把文档加工成可检索的数据结构，在线阶段负责把用户 Query 转成候选 Chunk、融合排序、精排并交给 LLM。</p>

<p>整体数据流如下：</p>

<pre><code class="language-mermaid">flowchart TD
    A[原始文档] --&gt; B[文档解析与清洗]
    B --&gt; C[按结构切分 Chunk]
    C --&gt; D[写入 BM25 倒排索引]
    C --&gt; E[调用 Embedding 模型生成 Chunk 向量]
    E --&gt; F[写入向量数据库]

    U[用户 Query] --&gt; Q[Query Router]
    Q --&gt; Q1[识别硬 token / 领域词 / 意图]
    Q1 --&gt; S[生成检索策略]

    S --&gt; BQ[BM25 检索请求]
    S --&gt; VQ[向量检索请求]

    BQ --&gt; D
    VQ --&gt; QE[生成 Query Embedding]
    QE --&gt; F

    D --&gt; BR[BM25 Top-K Chunk]
    F --&gt; VR[Vector Top-K Chunk]

    BR --&gt; RRF[RRF 融合候选]
    VR --&gt; RRF

    RRF --&gt; RR[Cross-Encoder Rerank]
    RR --&gt; TOP[最终 Top-K Chunk]
    TOP --&gt; P[组装 Prompt]
    P --&gt; LLM[LLM 生成答案]
    LLM --&gt; O[返回答案与引用来源]
</code></pre>

<p>下面用一条模拟 Query 从头到尾走一遍。</p>

<h3 id="61-离线入库阶段">6.1 离线入库阶段</h3>

<p>假设知识库中有一篇文档《售后错误码手册.md》，其中包含如下内容：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>错误码 E1234 表示支付渠道超时。
如果用户反馈支付失败后被重复扣款，需要先核验支付流水，再检查第三方支付网关回调状态。
确认重复扣款后，应创建退款工单，并在 1 个工作日内完成处理。
</code></pre></div></div>

<p>入库时，系统先解析文档结构，按标题、段落、表格和代码块边界切成 Chunk。每个 Chunk 会带上 <code class="language-plaintext highlighter-rouge">chunk_id</code>、<code class="language-plaintext highlighter-rouge">doc_id</code>、<code class="language-plaintext highlighter-rouge">source</code>、<code class="language-plaintext highlighter-rouge">title_path</code>、<code class="language-plaintext highlighter-rouge">text</code>、<code class="language-plaintext highlighter-rouge">updated_at</code> 等元数据。</p>

<p>然后，同一份 Chunk 同时写入 BM25 索引和向量索引。</p>

<p>写入 BM25 索引时，系统会对文本分词并构建倒排索引。写入向量索引时，系统会调用 Embedding 模型把 Chunk 文本编码成向量，再写入 Milvus、FAISS、Elasticsearch Vector、pgvector 或其他向量数据库。</p>

<p>入库后的数据可以抽象成下面这样：</p>

<table>
  <thead>
    <tr>
      <th>字段</th>
      <th>示例</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>chunk_id</td>
      <td>chunk_A</td>
    </tr>
    <tr>
      <td>doc_id</td>
      <td>aftersale_error_manual</td>
    </tr>
    <tr>
      <td>source</td>
      <td>售后错误码手册.md</td>
    </tr>
    <tr>
      <td>title_path</td>
      <td>支付错误码 / E1234</td>
    </tr>
    <tr>
      <td>text</td>
      <td>错误码 E1234 表示支付渠道超时。如果用户反馈支付失败后被重复扣款，需要先核验支付流水…</td>
    </tr>
    <tr>
      <td>bm25_index</td>
      <td>分词后写入倒排索引</td>
    </tr>
    <tr>
      <td>embedding</td>
      <td>[0.12, -0.31, 0.08, …]</td>
    </tr>
  </tbody>
</table>

<p>这个阶段结束后，知识库中同一份 Chunk 已经具备两种召回能力：BM25 可以通过 <code class="language-plaintext highlighter-rouge">E1234</code>、<code class="language-plaintext highlighter-rouge">支付失败</code>、<code class="language-plaintext highlighter-rouge">重复扣款</code> 等词项命中它；向量检索可以通过“付款后订单异常”“支付渠道超时处理”等语义表达召回它。</p>

<h3 id="62-在线查询阶段模拟-query-完整传输">6.2 在线查询阶段：模拟 Query 完整传输</h3>

<p>用户输入 Query：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>E1234 支付失败后用户被重复扣款怎么办？
</code></pre></div></div>

<p>第一步，API 网关或 RAG 服务接收请求，并生成一次查询链路的 <code class="language-plaintext highlighter-rouge">trace_id</code>。请求数据通常包含 <code class="language-plaintext highlighter-rouge">query</code>、<code class="language-plaintext highlighter-rouge">user_id</code>、<code class="language-plaintext highlighter-rouge">session_id</code>、<code class="language-plaintext highlighter-rouge">knowledge_base_id</code> 和业务过滤条件，例如只检索“售后知识库”。</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"trace_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"trace_20260603_001"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"E1234 支付失败后用户被重复扣款怎么办？"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"knowledge_base_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aftersale_kb"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"filters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"department"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aftersale"</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>第二步，Query Router 分析 Query。系统先对 Query 做轻量预处理，保留 <code class="language-plaintext highlighter-rouge">E1234</code> 这样的错误码，去掉无意义空格，并识别问句中的业务动作。</p>

<p>对这条 Query，Router 会抽取出三类信息。<code class="language-plaintext highlighter-rouge">E1234</code> 命中错误码正则，属于强关键词信号；“支付失败”和“重复扣款”属于业务实体和问题现象；“怎么办”表示用户需要处理流程，属于自然语言意图。</p>

<p>Router 可以把中间分析结果记录成结构化数据，方便排障：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"query_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hybrid_strong"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hard_tokens"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"E1234"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"domain_terms"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"支付失败"</span><span class="p">,</span><span class="w"> </span><span class="s2">"重复扣款"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"intent"</span><span class="p">:</span><span class="w"> </span><span class="s2">"troubleshooting"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"lexical_score"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.92</span><span class="p">,</span><span class="w">
  </span><span class="nl">"semantic_score"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.76</span><span class="p">,</span><span class="w">
  </span><span class="nl">"rewrite_needed"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"strategy"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"bm25_top_k"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vector_top_k"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w">
    </span><span class="nl">"preserve_tokens"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"E1234"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"fusion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rrf"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"rerank_top_n"</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span><span class="w">
    </span><span class="nl">"final_top_k"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>这里的 <code class="language-plaintext highlighter-rouge">lexical_score</code> 高，是因为 Query 中有 <code class="language-plaintext highlighter-rouge">E1234</code> 这种必须精确匹配的硬 token。<code class="language-plaintext highlighter-rouge">semantic_score</code> 也不低，是因为用户不是只问“E1234 是什么”，而是问“重复扣款怎么办”，需要召回处理流程类文档。</p>

<p>如果 Query 只有 <code class="language-plaintext highlighter-rouge">E1234</code>，Router 会采用更偏 BM25 的策略，例如 BM25 Top-50、Vector Top-10。如果 Query 是“用户付款后订单没生成怎么排查”，Router 会采用更偏向量的策略，例如 BM25 Top-20、Vector Top-50。如果 Query 是 <code class="language-plaintext highlighter-rouge">CreateOrderV2 timeout</code>，Router 会保留完整 API 名，并让 BM25 和代码文档索引优先参与。</p>

<p>第三步，系统并行请求 BM25 索引和向量索引。BM25 请求会携带原始 Query、分词后的词项和需要保护的硬 token；向量检索请求会先调用 Embedding 模型把 Query 编码成 Query Vector，再到向量数据库中查找近邻 Chunk。</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"bm25_request"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"E1234 支付失败后用户被重复扣款怎么办？"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"analyzed_terms"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"E1234"</span><span class="p">,</span><span class="w"> </span><span class="s2">"支付失败"</span><span class="p">,</span><span class="w"> </span><span class="s2">"支付"</span><span class="p">,</span><span class="w"> </span><span class="s2">"失败"</span><span class="p">,</span><span class="w"> </span><span class="s2">"重复扣款"</span><span class="p">,</span><span class="w"> </span><span class="s2">"处理"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"preserve_tokens"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"E1234"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"top_k"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w">
    </span><span class="nl">"filters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"department"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aftersale"</span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"vector_request"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"query_text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"E1234 支付失败后用户被重复扣款怎么办？"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"query_embedding"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mf">0.09</span><span class="p">,</span><span class="w"> </span><span class="mf">-0.22</span><span class="p">,</span><span class="w"> </span><span class="mf">0.17</span><span class="p">,</span><span class="w"> </span><span class="s2">"..."</span><span class="p">],</span><span class="w">
    </span><span class="nl">"top_k"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w">
    </span><span class="nl">"filters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"department"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aftersale"</span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>第四步，两路召回返回候选 Chunk。BM25 可能把 <code class="language-plaintext highlighter-rouge">chunk_A</code> 排在第一，因为它精确命中 <code class="language-plaintext highlighter-rouge">E1234</code>；向量检索可能把 <code class="language-plaintext highlighter-rouge">chunk_C</code> 排在第一，因为它语义上更像“支付渠道超时排查”。返回结果会保留各自通道的排名、原始分数和元数据，但后续 RRF 主要使用排名。</p>

<table>
  <thead>
    <tr>
      <th>通道</th>
      <th style="text-align: right">rank</th>
      <th>chunk_id</th>
      <th style="text-align: right">score</th>
      <th>摘要</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>BM25</td>
      <td style="text-align: right">1</td>
      <td>chunk_A</td>
      <td style="text-align: right">18.7</td>
      <td>E1234 表示支付渠道超时，重复扣款需核验流水并创建退款工单</td>
    </tr>
    <tr>
      <td>BM25</td>
      <td style="text-align: right">2</td>
      <td>chunk_B</td>
      <td style="text-align: right">12.1</td>
      <td>支付失败错误码总览，包含 E1234、E2008 等</td>
    </tr>
    <tr>
      <td>BM25</td>
      <td style="text-align: right">3</td>
      <td>chunk_D</td>
      <td style="text-align: right">6.4</td>
      <td>E1243 库存锁定失败处理流程</td>
    </tr>
    <tr>
      <td>Vector</td>
      <td style="text-align: right">1</td>
      <td>chunk_C</td>
      <td style="text-align: right">0.91</td>
      <td>支付渠道超时导致重复扣款的排查步骤</td>
    </tr>
    <tr>
      <td>Vector</td>
      <td style="text-align: right">2</td>
      <td>chunk_A</td>
      <td style="text-align: right">0.89</td>
      <td>E1234 表示支付渠道超时，重复扣款需核验流水并创建退款工单</td>
    </tr>
    <tr>
      <td>Vector</td>
      <td style="text-align: right">3</td>
      <td>chunk_F</td>
      <td style="text-align: right">0.84</td>
      <td>用户支付失败后的客服处理流程</td>
    </tr>
  </tbody>
</table>

<p>第五步，RRF 对两路候选进行融合。<code class="language-plaintext highlighter-rouge">chunk_A</code> 在 BM25 中排名第 1，在 Vector 中排名第 2，因此总分很高；<code class="language-plaintext highlighter-rouge">chunk_C</code> 虽然没有出现在 BM25 头部，但在 Vector 中排名第 1，也会被保留；<code class="language-plaintext highlighter-rouge">chunk_D</code> 虽然 BM25 命中了错误码形态，但它是 <code class="language-plaintext highlighter-rouge">E1243</code>，后续很可能被 Rerank 降权。</p>

<p>第六步，系统把 RRF 后的 Top-N 候选送入 Cross-Encoder Rerank。输入格式是一组 Query-Chunk Pair，模型为每个 Pair 输出相关性分数。假设 Rerank 后的结果如下：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">rerank_rank</th>
      <th>chunk_id</th>
      <th style="text-align: right">rerank_score</th>
      <th>摘要</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td>chunk_A</td>
      <td style="text-align: right">0.96</td>
      <td>E1234 表示支付渠道超时，重复扣款需核验流水并创建退款工单</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>chunk_C</td>
      <td style="text-align: right">0.88</td>
      <td>支付渠道超时导致重复扣款的排查步骤</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>chunk_B</td>
      <td style="text-align: right">0.72</td>
      <td>支付失败错误码总览，包含 E1234、E2008 等</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td>chunk_F</td>
      <td style="text-align: right">0.61</td>
      <td>用户支付失败后的客服处理流程</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td>chunk_D</td>
      <td style="text-align: right">0.04</td>
      <td>E1243 库存锁定失败处理流程</td>
    </tr>
  </tbody>
</table>

<p>第七步，系统取 Top-3 或 Top-4 Chunk 组装 Prompt。Prompt 中应包含用户问题、检索证据、来源信息和回答约束，要求 LLM 只基于证据回答，并在答案中引用来源。</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>你是售后知识库问答助手。请只基于以下资料回答用户问题；如果资料不足，请说明无法确认。

用户问题：
E1234 支付失败后用户被重复扣款怎么办？

检索证据：
[1] 来源：售后错误码手册.md / 支付错误码 / E1234
内容：E1234 表示支付渠道超时。若用户反馈支付失败后被重复扣款，需要先核验支付流水，再检查第三方支付网关回调状态。确认重复扣款后，应创建退款工单，并在 1 个工作日内完成处理。

[2] 来源：支付渠道异常排查.md / 渠道超时
内容：支付渠道超时可能导致支付结果回调延迟。客服应核验支付流水、订单状态和第三方回调日志，避免重复退款或漏退款。

[3] 来源：支付错误码总览.md
内容：E1234 表示支付渠道超时；E2008 表示风控拒绝；E1243 表示库存锁定失败。

请给出处理步骤，并标注引用来源。
</code></pre></div></div>

<p>第八步，LLM 基于这些 Chunk 生成答案。理想输出会明确指出 <code class="language-plaintext highlighter-rouge">E1234</code> 的含义、重复扣款的核验步骤、退款工单处理方式和来源引用，而不是泛泛回答“请联系客服”。这时，完整数据流已经从用户 Query 经过 Router、BM25、Embedding、向量库、RRF、Cross-Encoder Rerank，最终变成了 LLM 可使用的证据上下文。</p>

<h3 id="63-链路排障和评估">6.3 链路排障和评估</h3>

<p>线上排障时，一定要记录 BM25 Top-K、Vector Top-K、RRF Top-K 和 Rerank Top-K。只看最终答案无法判断问题发生在哪一层；只有保留分阶段 Trace，才能知道是 BM25 漏召回、向量漏召回、RRF 融合不合理，还是 Rerank 把正确 Chunk 排低了。</p>

<p>评估时不要只看整体准确率，还要按 Query 类型拆开看。错误码、订单号、型号、API 名和内部系统名属于硬 token 类，应重点看精确召回率和零结果率；自然语言描述类应重点看 Recall@K、MRR 和最终答案正确率；线上失败样例应沉淀成 Bad Case 回归集，每次调整 Query Router、RRF 参数、候选池大小或 Rerank 模型后都要回归。</p>

<table>
  <thead>
    <tr>
      <th>检查点</th>
      <th>目标</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>BM25 Top-K Trace</td>
      <td>判断精确 token 是否被召回</td>
    </tr>
    <tr>
      <td>Vector Top-K Trace</td>
      <td>判断语义相关候选是否被召回</td>
    </tr>
    <tr>
      <td>RRF Top-K Trace</td>
      <td>判断融合后是否保留关键候选</td>
    </tr>
    <tr>
      <td>Rerank Top-K Trace</td>
      <td>判断精排是否把正确 Chunk 排到前面</td>
    </tr>
    <tr>
      <td>零结果率</td>
      <td>监控两路召回都失败的比例</td>
    </tr>
    <tr>
      <td>Bad Case 回归集</td>
      <td>避免调参只提升平均值却伤害关键场景</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="7-总结">7. 总结</h2>

<p>BM25 和 Embedding 向量检索不是新旧替代关系，而是两类不同检索信号。</p>

<p><strong>BM25 看字面，Embedding 检索看语义。</strong> BM25 保证错误码、型号、API 名、内部系统名等硬 token 不漏，Embedding 检索保证自然语言描述和同义改写能被召回。</p>

<p>Hybrid Search 的关键也不是把两路原始分数粗暴相加，而是通过 Query Router 控制通道参与度，通过 RRF 按排名融合候选，再通过 Cross-Encoder Rerank 把最相关的 Chunk 放进 LLM Prompt。</p>

<p>如果只记一句话，可以这样概括：<strong>生产级 RAG 的检索链路应该按 Query 类型路由，用 BM25 保精确，用 Embedding 检索保语义，用 RRF 融合多路候选，用 Cross-Encoder Rerank 精排证据，最后再把少量高质量 Chunk 交给 LLM。</strong></p>]]></content><author><name>niuteng5618</name></author><category term="RAG" /><category term="检索与排序" /><category term="混合检索" /><category term="混合检索" /><category term="BM25" /><category term="Embedding" /><category term="RRF" /><category term="Rerank" /><summary type="html"><![CDATA[RAG 混合检索：BM25、Embedding 检索、RRF 与 Cross-Encoder Rerank 在企业知识库、售后工单、代码文档和内部系统问答中，检索链路不能只依赖一种召回方式。BM25 擅长精确字面匹配，Embedding 向量检索擅长语义相似匹配，RRF 负责把不同召回通道的结果按排名融合，Cross-Encoder Rerank 负责对融合后的候选 Chunk 做最终精排。 这篇文档聚焦一个核心问题：BM25 和向量检索不是二选一，而是要按 Query 类型协同工作。 对于 error_code=E1234、CreateOrderV2、OPS-Sentinel、iPhone 15 Pro Max 这类硬 token，关键词检索必须参与。 对于“用户支付失败后怎么排查”这类自然语言描述，Embedding 向量检索更有优势。生产级 RAG 通常会把两路召回合并，再通过 Rerank 控制最终进入 LLM 的证据质量。 1. 检索方式的核心分类 1.1 稀疏检索：以 BM25 为代表的关键词召回 稀疏检索（Sparse Retrieval）指基于词项、倒排索引和关键词权重进行召回的检索方式。它之所以叫“稀疏”，是因为系统会把文本映射到一个很大的词表空间中，而一段文本只会命中其中很少一部分词项，大多数位置都可以理解为 0。工程上通常不会真的保存一个巨大的稀疏数组，而是使用倒排索引记录“某个词出现在哪些文档或 Chunk 中”。 稀疏检索的代表技术是 BM25，下文会在 第 2 节：BM25：关键词精确匹配的主力 中详细解释它的公式、分词方式、工作原理和适用场景。 它最适合处理错误码、订单号、型号、API 名、版本号、法规条款、配置项和企业内部专有名词。原因很直接：这些信息的字面形式就是身份，差一个字符就可能代表完全不同的对象。 举例来说，用户问 E1234 支付失败怎么处理？，BM25 会明确区分 E1234 和 E1243。只要文档中出现了 E1234，它就能通过倒排索引快速命中；如果文档只出现 E1243，即使两个字符串看起来很像，也不会因为“长得像”而被当成同一个错误码。 1.2 稠密检索：以 Embedding 向量检索为代表的语义召回 稠密检索（Dense Retrieval）指基于 Embedding 向量进行语义相似度召回的检索方式。它之所以叫“稠密”，是因为一段文本会被模型编码成一个固定维度的连续向量，向量中的大多数维度都有数值，而不是像稀疏表示那样大面积为 0。 稠密检索的代表技术是 Embedding 向量检索，下文会在 第 3 节：Embedding 向量检索：语义召回的主力 中详细解释。 它最适合处理自然语言问题、长文本描述、同义改写和症状描述。例如“用户付款后订单没生成”和“支付成功但订单创建失败”字面不完全一致，但语义上高度相关，向量检索通常能把它们匹配到一起。 稠密检索的优势是语义泛化，缺点是对低频专有名词和硬 token 不够稳定。通用 Embedding 模型可能不知道企业内部的 飞翼平台、OPS-Sentinel、CreateOrderV2 分别代表什么，也不会天然保证 E1234 必须匹配包含 E1234 的文档。 1.3 混合检索：让关键词和语义各自发挥优势 混合检索（Hybrid Search）不是简单地把 BM25 分数和向量分数相加，而是让两类检索信号在同一条链路中互补。BM25 负责保证精确 token 不漏召回，Embedding 向量检索负责补充语义相关候选，RRF 负责对不同通道的排名进行融合，Cross-Encoder Rerank 再对候选 Chunk 做精排。 一个典型的混合检索链路可以概括为：用户 Query 进入 Query Router，系统判断它更偏硬 token 还是自然语言语义，然后分别请求 BM25 索引和向量索引得到候选 Chunk；候选 Chunk 经过 RRF 融合后形成统一候选池，再送入 Cross-Encoder Rerank 重新排序，最终只取少量高相关 Chunk 进入 LLM Prompt。 2. BM25：关键词精确匹配的主力 BM25 是经典的关键词检索算法。它不是简单统计“某个词出现了几次”，而是同时考虑词频、逆文档频率和文档长度归一化。 词频表示查询词在文档中出现的次数。逆文档频率表示这个词在整个语料中有多稀有，越稀有越有区分度。文档长度归一化用于避免长文档因为词多而天然获得更高分。 在真正计算 BM25 之前，系统会先做分词。分词的目标是把 Query 和 Chunk 都切成可统计的词项，也就是 BM25 公式里的 q_i。 中文场景下，分词通常依赖 Elasticsearch IK、jieba、HanLP、Lucene SmartCN 或自定义词典。英文场景下，分词通常会按空格、标点、大小写规范化、词干还原或词形归一化处理。 例如用户 Query 是： E1234 支付失败怎么处理？ 一个适合售后场景的分词结果可能是： [E1234, 支付失败, 支付, 失败, 处理] 这里最关键的是 E1234 不能被错误切碎，也不能被当成普通噪声词丢掉。对于错误码、订单号、API 名、配置项、版本号这类硬 token，生产系统通常会用正则和领域词典保护它们，让它们作为完整词项进入 BM25。 如果分词器把 CreateOrderV2 切成 Create、Order、V2，或者把 OPS-Sentinel 切丢了 Sentinel，BM25 后面的公式再正确也会召回不稳。因此 BM25 的工程效果不只取决于公式，也取决于分词器、停用词表、同义词表和领域词典。 BM25 的常见公式如下： \[score(D,Q)=\sum_{q_i \in Q} IDF(q_i) \cdot \frac{f(q_i,D)\cdot(k_1+1)}{f(q_i,D)+k_1\cdot(1-b+b\cdot\frac{|D|}{avgdl})}\] 其中，D 表示文档或 Chunk，Q 表示用户 Query，q_i 表示 Query 分词后的某个词项。 f(q_i,D) 表示这个词项在文档中出现的次数，IDF(q_i) 表示这个词项的稀有程度，|D| 表示当前文档长度，avgdl 表示语料库中文档的平均长度。 k1 控制词频增长的饱和速度，b 控制文档长度归一化强度。 从直觉上看，BM25 做了三件事：Query 里的词在文档里出现，文档就应该加分；一个词如果在全库里很少见，它比“的、是、怎么”这类常见词更重要；长文档不能因为内容多、碰巧命中更多词就无条件占优势。 下面用一个售后工单场景说明。用户 Query 是： E1234 支付失败怎么处理？ 候选 Chunk 如下： Chunk 内容 A 错误码 E1234 表示支付渠道超时，请检查第三方支付网关，并按重复扣款流程核验。 B 错误码 E1243 表示库存锁定失败，请检查库存服务和库存回滚日志。 C 支付失败可能由网络异常、余额不足、渠道超时或风控拒绝导致。 BM25 会认为 Chunk A 同时命中 E1234、支付失败 和 处理 相关词，其中 E1234 由于在全库中非常稀有，会带来很强的区分度。Chunk C 虽然语义上和支付失败有关，但没有命中 E1234；Chunk B 虽然同样是错误码文档，却命中的是 E1243，不能替代 E1234。因此在这类 Query 中，BM25 的精确匹配优势非常明显。 BM25 特别适合错误码、订单号、产品型号、API 名、函数名、版本号、法规条款、SKU、配置项和内部系统代号。它的工程优势也很明确：不需要训练数据，不需要 GPU，基于 Elasticsearch、OpenSearch、Lucene 等系统即可落地；命中哪个词、贡献了多少分也更容易通过日志解释。 3. Embedding 向量检索：语义召回的主力 Embedding 向量检索会先用 Embedding 模型把 Query 和 Chunk 分别编码成向量，再计算向量之间的相似度。语义越接近的文本，向量空间中的距离通常越近。常见相似度计算方式包括余弦相似度、点积和欧氏距离，实际使用哪一种取决于模型训练方式和向量数据库配置。 以自然语言问题为例，用户 Query 是： 用户付款后订单没生成，应该怎么排查？ 知识库中可能没有完全相同的句子，但有一个 Chunk 写着： 支付成功但订单创建失败时，需要依次检查支付回调、订单服务幂等表和消息队列消费状态。 BM25 可能只命中“支付”或“订单”等普通词，排序不一定靠前；Embedding 向量检索则能理解“付款后订单没生成”和“支付成功但订单创建失败”语义接近，因此更容易召回正确文档。 但对 E1234、E1243、CreateOrderV2 这类字符串，Embedding 模型的可靠性会下降。原因可以合并理解为：这类字符串语义信息很少，通用训练语料覆盖不足，分词结果又可能被切成多个子 token；而向量相似度本质上是软匹配，不会天然保证“必须包含这个精确字符串”。因此 E1234 和 E1243 在业务上完全不同，但在纯向量空间中可能被认为形式相近，导致召回错误 Chunk。 对企业内部低频专有名词也类似。飞翼平台、OPS-Sentinel、星河工单系统 这类词对企业内部人员很明确，但通用 Embedding 模型未必学过它们的业务含义。用户问“飞翼平台怎么申请权限”，纯向量检索可能召回其他平台的权限文档；BM25 则能直接锁定包含“飞翼平台”的 Chunk。所以在企业 RAG 中，Embedding 检索负责语义泛化，BM25 负责精确约束，两者缺一不可。 4. Hybrid 融合：Query Router、RRF 与动态权重 Hybrid 的第一步不是融合分数，而是判断 Query 类型。Query Router 是一个查询路由模块，用来判断当前 Query 更依赖关键词还是语义。 Query Router 不一定要用大模型。生产系统里更常见的做法是先用规则、正则表达式、领域词典和少量统计特征实现一个轻量 Router。只有当 Query 很复杂、规则无法判断时，才考虑引入小模型或 LLM 做二级分类。 4.1 Query Router 如何实现 Query Router 的输入通常是原始 Query、用户所属业务线、知识库 ID、会话上下文和权限过滤条件。它的输出不是最终答案，而是一份检索策略，例如走 BM25 还是向量检索、两路分别取多少 Top-K、是否需要 Query Rewrite、是否要保护精确 token。 这部分不需要做得特别复杂。工程上可以先用规则实现一个稳定版本，再根据 bad case 决定是否引入 LLM 分类。 flowchart TD A[用户 Query] --&gt; B[预处理] B --&gt; C[识别硬 token / 领域词 / 意图] C --&gt; D{是否需要改写?} D --&gt;|是| E[Query Rewrite / Query Expansion] D --&gt;|否| F[Query 分类] E --&gt; F F --&gt; G{Query 类型} G --&gt;|精确匹配型| H[偏 BM25] G --&gt;|语义描述型| I[偏向量检索] G --&gt;|混合型| J[BM25 + 向量并行] G --&gt;|不确定| K[默认 Hybrid] H --&gt; L[输出检索策略] I --&gt; L J --&gt; L K --&gt; L 一个简单 Router 可以按下面流程实现。 第一步：预处理 Query。 这一步做大小写归一化、全角半角转换、空格清洗和无意义标点清理，但不能破坏关键符号。E1234、ORD-20260503-7788、CreateOrderV2、max_retry_count 里的大小写、连字符和下划线都应该保留。 第二步：识别强规则信号。 如果 Query 命中错误码、订单号、版本号、SKU、API 名、配置项、文件路径、URL、法规条款编号，就可以认为它有强关键词约束。这类 Query 必须让 BM25 参与。 第三步：识别语义信号。 如果 Query 是完整自然语言问题，包含“为什么、怎么、如何、怎么排查、有什么区别”等问法，或者是一整段工单描述，就说明它需要语义召回。这类 Query 必须让向量检索参与。 第四步：判断是否需要 Query Rewrite。 如果 Query 太口语化、指代不清或依赖上下文，例如“这个报错怎么处理”，就应该结合会话上下文改写成更完整的 Query。如果 Query 很短但同义表达很多，例如“重复扣款”，可以做 Query Expansion，补充“重复支付、重复扣费、支付流水核验、退款工单”等同义词或相关术语。 第五步：输出检索策略。 Router 最终只需要给出可执行策略，不需要给出答案。策略可以包括 bm25_top_k、vector_top_k、是否使用 RRF、是否进入 Rerank、是否保护某些 token。 关于 lexical_score 和 semantic_score，它们不是必须存在的模型分数，也不是让 LLM 随便打一个分。更常见的做法是规则加权，便于调试。 lexical_score 可以理解为“字面精确匹配分”。 它衡量当前 Query 有多依赖关键词、编号、代码、错误码、产品型号、字段名、法规条款这类精确字面信息。这个分数越高，越说明 BM25 不能缺席。 semantic_score 可以理解为“语义理解分”。 它衡量当前 Query 有多依赖自然语言理解、同义表达、问题意图、上下文描述和推理型检索。这个分数越高，越说明向量检索不能缺席。 规则加权可以从简单版本开始。比如命中一个错误码，lexical_score += 3；命中订单号、SKU、版本号、法规条款，lexical_score += 3；命中 API 名、函数名、配置项，lexical_score += 2；命中领域词典里的内部系统名或产品名，lexical_score += 2。 语义侧也可以类似处理。如果 Query 是完整问句，semantic_score += 2；如果包含“怎么排查、为什么、如何处理、有什么区别、能不能”这类意图词，semantic_score += 2；如果 Query 是一段较长的工单描述，semantic_score += 2；如果命中了同义词扩展词表，semantic_score += 1。 一个简单判断策略如下： 判断条件 路由结果 示例 lexical_score &gt;= 3 且 semantic_score &lt; 2 偏 BM25 E1234 是什么意思？ semantic_score &gt;= 3 且 lexical_score &lt; 2 偏向量检索 用户付款后订单没生成怎么排查？ lexical_score &gt;= 3 且 semantic_score &gt;= 2 强 Hybrid E1234 支付失败后重复扣款怎么办？ 两个分数都低 默认 Hybrid 退款失败 两个分数接近但 Query 很复杂 LLM 兜底分类 客户说昨天付了两次钱但订单状态还是失败，这种算什么问题？ 这些阈值不是标准答案，而是初始配置。上线后应该根据 bad case 调整，例如错误码漏召回多，就降低 BM25 触发门槛；自然语言问题召回不准，就提高向量侧参与度。 当然，也可以直接用 LLM 做 Query 分类。LLM Router 的优点是能理解复杂语义、多意图问题和上下文省略，缺点是成本更高、延迟更高、结果稳定性不如规则，并且需要对输出做 JSON schema 约束。 更稳的做法是规则优先，LLM 兜底。以下情况适合触发 LLM 兜底：规则分数接近、Query 同时包含多个意图、Query 依赖上下文指代、用户输入是一整段工单描述、Query 需要先改写才能检索，或者规则命中了很多信号但互相冲突。 LLM 兜底不是让模型直接回答问题，而是让模型只做分类和改写。它应该输出固定 JSON，字段包括 Query 类型、是否需要改写、改写后的 Query、需要保护的硬 token、BM25 Top-K、Vector Top-K、是否使用 RRF、是否使用 Rerank。 LLM 兜底还要有安全策略。只要 Query 中存在错误码、订单号、API 名、版本号等硬 token，即使 LLM 判断为语义型，也不能关闭 BM25；最多只能调整两路 Top-K。LLM 不能删除硬 token，不能把 E1234 改写成 E1243，也不能凭空扩展不存在的业务实体。 LLM 分类可以要求输出固定 JSON，例如： { "query_type": "hybrid", "need_rewrite": true, "rewrite_query": "E1234 支付渠道超时导致重复扣款时如何处理？", "route": { "bm25_top_k": 50, "vector_top_k": 50, "use_rrf": true, "use_rerank": true }, "preserve_tokens": ["E1234"] } 不同情况可以按下面方式处理： Query 类型 示例 推荐处理 精确匹配型 E1234 是什么意思？ 偏 BM25，保护 E1234，向量少量补充 语义描述型 用户付款后订单没生成怎么排查？ 偏向量检索，BM25 少量补充关键词候选 混合型 E1234 支付失败后重复扣款怎么办？ BM25 和向量都取较大 Top-K，再 RRF + Rerank 代码/API 型 CreateOrderV2 timeout 怎么处理？ 偏 BM25，保护 API 名，同时召回语义排查文档 上下文省略型 这个报错怎么处理？ 先 Query Rewrite，再 Hybrid 检索 同义词扩展型 重复扣款 Query Expansion 后再 Hybrid 检索 不确定型 退款失败 默认 Hybrid，后续依赖 RRF 和 Rerank 兜底 Router 的目标不是一次分类永远正确，而是避免明显错误。错误码类 Query 不能被纯向量检索带偏，自然语言问题也不能只靠关键词硬匹配。只要 Router 能把大部分 Query 分到合理通道，后面的 RRF 和 Rerank 就会轻松很多。 4.2 为什么不能直接相加分数 常见错误写法是： \[final\_score=0.5\times bm25\_score+0.5\times vector\_score\] 这个公式的问题在于，BM25 分数和向量相似度不是同一种度量。BM25 分数没有固定上限，会随着词频、IDF、文档长度和语料分布变化；余弦相似度通常落在较窄区间，例如 0.7 到 0.95。直接相加时，BM25 数值可能天然压过向量分数，导致语义检索结果几乎不参与排序。 举例来说，Chunk A 的 BM25 分数是 18.0，向量相似度是 0.60；Chunk B 的 BM25 分数是 2.0，向量相似度是 0.92。直接按 0.5 加权后，A 的融合分是 9.30，B 的融合分是 1.46。虽然 B 在语义上更匹配，但因为 BM25 原始数值更大，最终被压下去了。 归一化加权并不是不能用，但要谨慎。Min-Max 归一化和 Z-score 标准化可以把分数映射到相近范围，但它们会受到异常值、Query 分布变化和语料变化影响。更稳妥的起步方式是先用 RRF，只看每个通道内部的排名，不直接比较不同通道的原始分数。 4.3 RRF 的原理和完整排名示例 RRF（Reciprocal Rank Fusion，倒数排名融合）是一种按排名融合多路检索结果的方法。它不关心 BM25 分数是多少，也不关心向量相似度是多少，只关心某个 Chunk 在每一路检索结果中排第几。排名越靠前，贡献越大；同一个 Chunk 如果在多路检索中都靠前，总分会自然升高。 RRF 的标准公式如下： \[RRF(d)=\sum_{i=1}^{n}\frac{1}{c+rank_i(d)}\] 其中，d 表示候选 Chunk，n 表示检索通道数量，rank_i(d) 表示 Chunk d 在第 i 个检索通道中的排名，c 是平滑常数，常见取值为 60。如果某个 Chunk 没有出现在某一路结果中，这一路通常不给它贡献分数。 假设用户 Query 是： E1234 支付失败后用户被重复扣款怎么办？ BM25 和向量检索分别返回 Top-5： BM25 排名 Chunk 内容摘要 1 A E1234 错误码处理说明 2 B 支付失败错误码总览 3 C 支付渠道超时排查 4 D 库存错误码说明 5 E 售后工单字段解释 Vector 排名 Chunk 内容摘要 1 C 支付渠道超时排查 2 F 用户支付失败后的处理流程 3 B 支付失败错误码总览 4 A E1234 错误码处理说明 5 G 第三方支付网关异常说明 取 c = 60，分别计算每个 Chunk 的 RRF 分数： Chunk BM25 贡献 Vector 贡献 RRF 总分 解释 A 1 / (60 + 1) = 0.01639 1 / (60 + 4) = 0.01563 0.03202 BM25 第一，向量也召回 B 1 / (60 + 2) = 0.01613 1 / (60 + 3) = 0.01587 0.03200 两路都比较靠前 C 1 / (60 + 3) = 0.01587 1 / (60 + 1) = 0.01639 0.03226 向量第一，BM25 也靠前 D 1 / (60 + 4) = 0.01563 0 0.01563 只在 BM25 出现 E 1 / (60 + 5) = 0.01538 0 0.01538 只在 BM25 出现 F 0 1 / (60 + 2) = 0.01613 0.01613 只在向量结果中靠前 G 0 1 / (60 + 5) = 0.01538 0.01538 只在向量结果中出现 最终按 RRF 总分从高到低排序，得到融合排名： 最终排名 Chunk RRF 总分 为什么排在这里 1 C 0.03226 向量第一，BM25 第三，两路共同认可 2 A 0.03202 BM25 第一，且向量也召回，硬 token 证据强 3 B 0.03200 两路都靠前，但都不是第一 4 F 0.01613 只在向量中出现，但向量排名第二 5 D 0.01563 只在 BM25 中出现，排名第四 6 E / G 0.01538 单路靠后，分数相同可再按单路分数或稳定排序规则打破平局 这个例子说明了 RRF 如何得到最终排名：先把每一路检索结果中的名次转成倒数贡献，再把同一个 Chunk 在不同通道中的贡献相加，最后按总分排序。它天然解决了 BM25 分数和向量分数尺度不一致的问题，也保留了“单路非常靠前”的候选资格。 5. Rerank 与 Cross-Encoder：关系、原理、结构和调用格式 Rerank（重排序、精排）是一道排序阶段，不是某一种固定模型。它的输入是一批已经召回的候选 Chunk，输出是这些候选 Chunk 针对当前 Query 的相关性排序。Cross-Encoder（交叉编码器）是实现 Rerank 的常见模型结构之一，也是 RAG 系统中最常见的精排方案之一。简单说，Rerank 是任务，Cross-Encoder 是经常用来完成这个任务的模型类型。 RRF 解决的是多路召回候选如何合并，Cross-Encoder Rerank 解决的是候选中哪几条真正适合放进 Prompt。RRF 只看排名，不理解 Query 和 Chunk 的逐 token 交互；Cross-Encoder 会把 Query 和 Chunk 放在一起输入模型，让模型直接判断二者是否相关，因此判断更细，但计算成本也更高。 5.1 Cross-Encoder 的工作原理 Cross-Encoder 的典型输入不是单独的 Query 向量或单独的文档向量，而是一个 Query-Chunk 对。模型会把二者拼接成一个序列，例如： [CLS] E1234 支付失败后用户被重复扣款怎么办？ [SEP] E1234 表示支付渠道超时，若发生重复扣款，请按照退款工单流程处理。 [SEP] 然后模型通过 Transformer 层让 Query 中的 token 和 Chunk 中的 token 充分交互。最后，模型通常取 [CLS] 位置的表示，接一个线性层或分类头，输出一个相关性分数。这个分数可以理解为“当前 Chunk 对当前 Query 有多相关”。 它和向量检索的差异非常关键。向量检索通常是 Bi-Encoder 架构，Query 和 Chunk 分别编码，提前把 Chunk 向量存进向量库，线上只需要编码 Query 并做近邻搜索，因此速度快、适合大规模召回。Cross-Encoder 则必须对每个 Query-Chunk 对重新计算，无法提前把所有 Chunk 的最终相关性算好，因此速度慢，但排序更准。 5.2 Cross-Encoder 的模型结构 Cross-Encoder 的结构可以抽象为： Query + Chunk ↓ Tokenizer 拼接为一个输入序列 ↓ Transformer Encoder 编码 ↓ 取 [CLS] 或池化后的整体表示 ↓ 线性层 / 分类头输出相关性分数 ↓ 按分数从高到低排序 如果用更数学化的方式表示，可以写成： \[score(q,d)=Head(Encoder([q;d]))\] 其中，q 表示 Query，d 表示候选 Chunk，[q;d] 表示把 Query 和 Chunk 拼接后输入模型，Encoder 通常是 BERT、RoBERTa、DeBERTa 或其他 Transformer Encoder，Head 是输出相关性分数的分类头或回归头。 Cross-Encoder 的输入长度通常有限制，例如 512、1024 或 2048 tokens。如果 Chunk 太长，需要在召回阶段就控制 Chunk 长度，或者在 Rerank 前进行截断。Rerank 的候选数量也不能太大，常见做法是先用 BM25 和向量检索召回几十到几百条，再用 Cross-Encoder 精排 Top-50 或 Top-100，最后取 Top-3 到 Top-5 进入 LLM。 6. 生产级 RAG 检索链路：从 Query 到 LLM 的完整数据流 生产级链路需要同时描述离线入库和在线查询。离线阶段负责把文档加工成可检索的数据结构，在线阶段负责把用户 Query 转成候选 Chunk、融合排序、精排并交给 LLM。 整体数据流如下： flowchart TD A[原始文档] --&gt; B[文档解析与清洗] B --&gt; C[按结构切分 Chunk] C --&gt; D[写入 BM25 倒排索引] C --&gt; E[调用 Embedding 模型生成 Chunk 向量] E --&gt; F[写入向量数据库] U[用户 Query] --&gt; Q[Query Router] Q --&gt; Q1[识别硬 token / 领域词 / 意图] Q1 --&gt; S[生成检索策略] S --&gt; BQ[BM25 检索请求] S --&gt; VQ[向量检索请求] BQ --&gt; D VQ --&gt; QE[生成 Query Embedding] QE --&gt; F D --&gt; BR[BM25 Top-K Chunk] F --&gt; VR[Vector Top-K Chunk] BR --&gt; RRF[RRF 融合候选] VR --&gt; RRF RRF --&gt; RR[Cross-Encoder Rerank] RR --&gt; TOP[最终 Top-K Chunk] TOP --&gt; P[组装 Prompt] P --&gt; LLM[LLM 生成答案] LLM --&gt; O[返回答案与引用来源] 下面用一条模拟 Query 从头到尾走一遍。 6.1 离线入库阶段 假设知识库中有一篇文档《售后错误码手册.md》，其中包含如下内容： 错误码 E1234 表示支付渠道超时。 如果用户反馈支付失败后被重复扣款，需要先核验支付流水，再检查第三方支付网关回调状态。 确认重复扣款后，应创建退款工单，并在 1 个工作日内完成处理。 入库时，系统先解析文档结构，按标题、段落、表格和代码块边界切成 Chunk。每个 Chunk 会带上 chunk_id、doc_id、source、title_path、text、updated_at 等元数据。 然后，同一份 Chunk 同时写入 BM25 索引和向量索引。 写入 BM25 索引时，系统会对文本分词并构建倒排索引。写入向量索引时，系统会调用 Embedding 模型把 Chunk 文本编码成向量，再写入 Milvus、FAISS、Elasticsearch Vector、pgvector 或其他向量数据库。 入库后的数据可以抽象成下面这样： 字段 示例 chunk_id chunk_A doc_id aftersale_error_manual source 售后错误码手册.md title_path 支付错误码 / E1234 text 错误码 E1234 表示支付渠道超时。如果用户反馈支付失败后被重复扣款，需要先核验支付流水… bm25_index 分词后写入倒排索引 embedding [0.12, -0.31, 0.08, …] 这个阶段结束后，知识库中同一份 Chunk 已经具备两种召回能力：BM25 可以通过 E1234、支付失败、重复扣款 等词项命中它；向量检索可以通过“付款后订单异常”“支付渠道超时处理”等语义表达召回它。 6.2 在线查询阶段：模拟 Query 完整传输 用户输入 Query： E1234 支付失败后用户被重复扣款怎么办？ 第一步，API 网关或 RAG 服务接收请求，并生成一次查询链路的 trace_id。请求数据通常包含 query、user_id、session_id、knowledge_base_id 和业务过滤条件，例如只检索“售后知识库”。 { "trace_id": "trace_20260603_001", "query": "E1234 支付失败后用户被重复扣款怎么办？", "knowledge_base_id": "aftersale_kb", "filters": {"department": "aftersale"} } 第二步，Query Router 分析 Query。系统先对 Query 做轻量预处理，保留 E1234 这样的错误码，去掉无意义空格，并识别问句中的业务动作。 对这条 Query，Router 会抽取出三类信息。E1234 命中错误码正则，属于强关键词信号；“支付失败”和“重复扣款”属于业务实体和问题现象；“怎么办”表示用户需要处理流程，属于自然语言意图。 Router 可以把中间分析结果记录成结构化数据，方便排障： { "query_type": "hybrid_strong", "hard_tokens": ["E1234"], "domain_terms": ["支付失败", "重复扣款"], "intent": "troubleshooting", "lexical_score": 0.92, "semantic_score": 0.76, "rewrite_needed": false, "strategy": { "bm25_top_k": 50, "vector_top_k": 50, "preserve_tokens": ["E1234"], "fusion": "rrf", "rerank_top_n": 20, "final_top_k": 3 } } 这里的 lexical_score 高，是因为 Query 中有 E1234 这种必须精确匹配的硬 token。semantic_score 也不低，是因为用户不是只问“E1234 是什么”，而是问“重复扣款怎么办”，需要召回处理流程类文档。 如果 Query 只有 E1234，Router 会采用更偏 BM25 的策略，例如 BM25 Top-50、Vector Top-10。如果 Query 是“用户付款后订单没生成怎么排查”，Router 会采用更偏向量的策略，例如 BM25 Top-20、Vector Top-50。如果 Query 是 CreateOrderV2 timeout，Router 会保留完整 API 名，并让 BM25 和代码文档索引优先参与。 第三步，系统并行请求 BM25 索引和向量索引。BM25 请求会携带原始 Query、分词后的词项和需要保护的硬 token；向量检索请求会先调用 Embedding 模型把 Query 编码成 Query Vector，再到向量数据库中查找近邻 Chunk。 { "bm25_request": { "query": "E1234 支付失败后用户被重复扣款怎么办？", "analyzed_terms": ["E1234", "支付失败", "支付", "失败", "重复扣款", "处理"], "preserve_tokens": ["E1234"], "top_k": 50, "filters": {"department": "aftersale"} }, "vector_request": { "query_text": "E1234 支付失败后用户被重复扣款怎么办？", "query_embedding": [0.09, -0.22, 0.17, "..."], "top_k": 50, "filters": {"department": "aftersale"} } } 第四步，两路召回返回候选 Chunk。BM25 可能把 chunk_A 排在第一，因为它精确命中 E1234；向量检索可能把 chunk_C 排在第一，因为它语义上更像“支付渠道超时排查”。返回结果会保留各自通道的排名、原始分数和元数据，但后续 RRF 主要使用排名。 通道 rank chunk_id score 摘要 BM25 1 chunk_A 18.7 E1234 表示支付渠道超时，重复扣款需核验流水并创建退款工单 BM25 2 chunk_B 12.1 支付失败错误码总览，包含 E1234、E2008 等 BM25 3 chunk_D 6.4 E1243 库存锁定失败处理流程 Vector 1 chunk_C 0.91 支付渠道超时导致重复扣款的排查步骤 Vector 2 chunk_A 0.89 E1234 表示支付渠道超时，重复扣款需核验流水并创建退款工单 Vector 3 chunk_F 0.84 用户支付失败后的客服处理流程 第五步，RRF 对两路候选进行融合。chunk_A 在 BM25 中排名第 1，在 Vector 中排名第 2，因此总分很高；chunk_C 虽然没有出现在 BM25 头部，但在 Vector 中排名第 1，也会被保留；chunk_D 虽然 BM25 命中了错误码形态，但它是 E1243，后续很可能被 Rerank 降权。 第六步，系统把 RRF 后的 Top-N 候选送入 Cross-Encoder Rerank。输入格式是一组 Query-Chunk Pair，模型为每个 Pair 输出相关性分数。假设 Rerank 后的结果如下： rerank_rank chunk_id rerank_score 摘要 1 chunk_A 0.96 E1234 表示支付渠道超时，重复扣款需核验流水并创建退款工单 2 chunk_C 0.88 支付渠道超时导致重复扣款的排查步骤 3 chunk_B 0.72 支付失败错误码总览，包含 E1234、E2008 等 4 chunk_F 0.61 用户支付失败后的客服处理流程 5 chunk_D 0.04 E1243 库存锁定失败处理流程 第七步，系统取 Top-3 或 Top-4 Chunk 组装 Prompt。Prompt 中应包含用户问题、检索证据、来源信息和回答约束，要求 LLM 只基于证据回答，并在答案中引用来源。 你是售后知识库问答助手。请只基于以下资料回答用户问题；如果资料不足，请说明无法确认。 用户问题： E1234 支付失败后用户被重复扣款怎么办？ 检索证据： [1] 来源：售后错误码手册.md / 支付错误码 / E1234 内容：E1234 表示支付渠道超时。若用户反馈支付失败后被重复扣款，需要先核验支付流水，再检查第三方支付网关回调状态。确认重复扣款后，应创建退款工单，并在 1 个工作日内完成处理。 [2] 来源：支付渠道异常排查.md / 渠道超时 内容：支付渠道超时可能导致支付结果回调延迟。客服应核验支付流水、订单状态和第三方回调日志，避免重复退款或漏退款。 [3] 来源：支付错误码总览.md 内容：E1234 表示支付渠道超时；E2008 表示风控拒绝；E1243 表示库存锁定失败。 请给出处理步骤，并标注引用来源。 第八步，LLM 基于这些 Chunk 生成答案。理想输出会明确指出 E1234 的含义、重复扣款的核验步骤、退款工单处理方式和来源引用，而不是泛泛回答“请联系客服”。这时，完整数据流已经从用户 Query 经过 Router、BM25、Embedding、向量库、RRF、Cross-Encoder Rerank，最终变成了 LLM 可使用的证据上下文。 6.3 链路排障和评估 线上排障时，一定要记录 BM25 Top-K、Vector Top-K、RRF Top-K 和 Rerank Top-K。只看最终答案无法判断问题发生在哪一层；只有保留分阶段 Trace，才能知道是 BM25 漏召回、向量漏召回、RRF 融合不合理，还是 Rerank 把正确 Chunk 排低了。 评估时不要只看整体准确率，还要按 Query 类型拆开看。错误码、订单号、型号、API 名和内部系统名属于硬 token 类，应重点看精确召回率和零结果率；自然语言描述类应重点看 Recall@K、MRR 和最终答案正确率；线上失败样例应沉淀成 Bad Case 回归集，每次调整 Query Router、RRF 参数、候选池大小或 Rerank 模型后都要回归。 检查点 目标 BM25 Top-K Trace 判断精确 token 是否被召回 Vector Top-K Trace 判断语义相关候选是否被召回 RRF Top-K Trace 判断融合后是否保留关键候选 Rerank Top-K Trace 判断精排是否把正确 Chunk 排到前面 零结果率 监控两路召回都失败的比例 Bad Case 回归集 避免调参只提升平均值却伤害关键场景 7. 总结 BM25 和 Embedding 向量检索不是新旧替代关系，而是两类不同检索信号。 BM25 看字面，Embedding 检索看语义。 BM25 保证错误码、型号、API 名、内部系统名等硬 token 不漏，Embedding 检索保证自然语言描述和同义改写能被召回。 Hybrid Search 的关键也不是把两路原始分数粗暴相加，而是通过 Query Router 控制通道参与度，通过 RRF 按排名融合候选，再通过 Cross-Encoder Rerank 把最相关的 Chunk 放进 LLM Prompt。 如果只记一句话，可以这样概括：生产级 RAG 的检索链路应该按 Query 类型路由，用 BM25 保精确，用 Embedding 检索保语义，用 RRF 融合多路候选，用 Cross-Encoder Rerank 精排证据，最后再把少量高质量 Chunk 交给 LLM。]]></summary></entry><entry><title type="html">Attention机制对比学习</title><link href="https://niuteng5618.github.io/001-attention/" rel="alternate" type="text/html" title="Attention机制对比学习" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>https://niuteng5618.github.io/001-attention</id><content type="html" xml:base="https://niuteng5618.github.io/001-attention/"><![CDATA[<p><strong>注意力机制</strong>通过<strong>查询（Query, Q）</strong>、<strong>键（Key, K）</strong>和**值（Value, V）三个向量来完成。</p>

<ul>
  <li><strong>Query (Q)</strong>: 像一个“问题”，代表当前词想从其他词那里寻找什么信息。</li>
  <li><strong>Key (K)</strong>: 像一个“索引”，代表每个词能提供什么信息。</li>
  <li><strong>Value (V)</strong>: 像一个“答案”，代表每个词所包含的具体内容。</li>
</ul>

<p>注意力机制的工作流程就是：用当前词的 <strong>Q</strong> 去匹配所有词的 <strong>K</strong>，找出最相关的词，然后根据相关性分数，对所有词的 <strong>V</strong> 进行加权求和，得到当前词的最终表示。</p>

<h2 id="mha--gqa--mqa--mla-对比学习">MHA &amp; GQA &amp; MQA &amp; MLA 对比学习</h2>
<p><img src="/images/yuque/001-attention/image-1-b668318a.png" alt="" /></p>

<h3 id="mha-multi-head-attention-多头注意力">MHA: Multi-Head Attention (多头注意力)</h3>
<p>MHA 的做法是，将每个 token 的 <strong>Q、K、V</strong> 向量<strong>拆分成多个小份</strong>（即多个“头”），每个“头”独立地进行一次注意力计算。最后，把所有“头”的计算结果拼接起来，再经过一个线性层，得到最终的输出。</p>

<p><strong>优势：</strong> 这种并行处理让模型能够同时捕捉到输入序列中各种不同类型、不同层面的信息，大大增强了模型的表达能力。</p>

<p><strong>缺点：</strong> 在生成长文本时，每个 token 都会产生一套完整的 Q、K、V 向量，并且都需要被缓存（即 <strong>KV cache</strong>）。当序列长度增加时，KV cache 的大小会线性增长，导致占用大量的显存（GPU 内存），成为推理速度的瓶颈。</p>

<h4 id="疑问解答">疑问解答</h4>
<p><strong>问题：</strong>输入数据是一致的，那么多个head是如何实现独立计算的？</p>

<p>回答：</p>

<ul>
  <li><strong>输入到多头注意力层的数据是相同的</strong>。但每个“头”都有自己<strong>独一无二的权重矩阵</strong>。</li>
  <li>MHA会为每个头分配大小相同但<strong>随机初始化</strong>的权重矩阵，来实现独立的注意力计算。</li>
  <li>在模型训练过程中，这些随机初始化的权重会通过反向传播<strong>和</strong>梯度下降算法进行调整。</li>
</ul>

<p>**问题： **为什么多个头的结果要拼接起来，经过一个线性层？直接拼完输出不行吗？</p>

<p>回答：</p>

<ul>
  <li>拼接的含义不是相加，更像是追加。因此多个head的结果拼在一起，可能会导致结果特别长，和下一层的维度不匹配。</li>
  <li>拼接后使用Liner，可以重塑size，更好的对接下一层。</li>
  <li>拼接后使用Liner，可以学习如何将拼接后的长向量重新压缩成一个紧凑的、包含了所有关键信息的新向量，供后续的层继续处理。</li>
  <li>这个Liner更像是一个<strong>信息筛选、信息整合层</strong>。是至关重要的一层。</li>
</ul>

<h3 id="mqa-multi-query-attention-多查询注意力">MQA: Multi-Query Attention (多查询注意力)</h3>
<p>所有Q共享一组KV。</p>

<p>每个head对应一个query，所有head只有一对KV。</p>

<p>KV cache 大小<strong>大大减少，</strong>但整体效果较差。</p>

<h3 id="gqa-grouped-query-attention-分组查询注意力">GQA: Grouped-Query Attention (分组查询注意力)</h3>
<ul>
  <li>GQA 是一种介于 MHA 和 MQA 之间的折中方案</li>
  <li>将 Q 向量分成 G 个组，然后为每个组生成<strong>一组</strong> K 和 V 向量。当 G 等于 attention head 的数量时，GQA 就变成了 MHA；当 G 等于 1 时，GQA 就变成了 MQA。</li>
  <li>许多现代大型语言模型（如 Llama 2、Mixtral）都采用了 GQA。</li>
</ul>

<h3 id="mla-multi-head-latent-attention-多头潜在注意力--不太理解">MLA: Multi-Head Latent Attention (多头潜在注意力)  【不太理解】</h3>
<p>MLA 是 <strong>DeepSeek-V2</strong> 模型中引入的一种更新、更复杂的注意力机制。</p>

<p>MLA是MQA的低秩版本，通过将key和value压缩到latent空间存储，减少KV cache占用，并通过解压操作模拟多head注意力计算，性能不降低的同时减少了KVcache存储。</p>

<p><strong>通俗解释：</strong> MLA 不再是简单地共享 K 和 V，而是通过一种“压缩和解压”的技术来优化。</p>

<ul>
  <li>在模型推理时，它不是直接存储完整的 K 和 V 向量，而是将它们<strong>压缩</strong>成一个更小、更紧凑的“潜在向量”（latent vector）并存储在 KV cache 中。</li>
  <li>当需要计算注意力时，模型会从缓存中<strong>读取这个压缩的向量</strong>，并动态地“解压”成完整的 K 和 V 向量来使用。</li>
</ul>

<p><strong>优点：</strong>MLA 的主要优势在于<strong>极大地减小了 KV cache 的大小</strong>。因为它缓存的只是一个压缩后的向量，占用的显存比 MHA、MQA 和 GQA 都更少，这使得模型能够处理更长的上下文，特别是在需要处理超长序列时，优势非常明显。</p>

<h3 id="关于mla中的rope">关于MLA中的ROPE</h3>
<p>添加旋转位置编码：旋转位置编码一般是添加在Q和K上的，但是ROPE与输入的具体位置有关，无法像前面MLA attention中的其他参数矩阵一样可以合并并提前计算。因此这里采用了一个折中的解决方案，即额外维护了另一组压缩参数矩阵用于维护位置信息。</p>

<ul>
  <li>query的压缩参数矩阵为WQR，key的压缩参数矩阵为WKR，其中R代表ROPE，指针对query和key分别进行压缩，用于旋转位置编码。 被压缩后的query和key分别应用于ROPE，并将应用过ROPE的query和key的对应向量拼接到原本解压后的query和value向量上，具体操作参见计算图。</li>
  <li>被压缩后的query和key分别应用于ROPE，并将应用过ROPE的query和key的对应向量拼接到原本解压后的query和value向量上，具体操作参见计算图。</li>
  <li>需要注意的是MLA中采取了MQA相同的设计，即不同head的query使用不同的位置编码后信息，不同head的key使用相同的位置编码后信息。这导致query和key的压缩矩阵维度不同，query的压缩维度是head数倍hidden size， key的压缩维度是1倍hidden size。</li>
</ul>

<h4 id="个人理解">个人理解</h4>
<ul>
  <li>MLA 主要是为了减少KVcache， 节省计算和显存  。</li>
  <li>所谓压缩，就是添加一个Liner进行线性投影。解压也是同理，模型需要学习这个 压缩矩阵 和 解压矩阵。</li>
  <li>这里提到<strong>MLA的RoPE与原始RoPE不同</strong>，主要是由于<strong>标准的RoPE</strong>需要在计算 注意力分数  之前，针对每个token 的位置对QK进行旋转，从而引入位置信息。但MLA对QK进行了压缩，所谓的token位置已经不存在了，因此无法直接使用标准RoPE引入位置信息。</li>
  <li>MLA 的核心是把 Key 和 Value 向量压缩成一个“静态”的、与位置无关的潜在向量，并缓存起来。而 RoPE 是一种“动态”的位置编码，它的旋转操作依赖于每个 token 的具体位置索引。这两种机制在设计上是冲突的。</li>
  <li>关于<strong>key和value使用同一个矩阵</strong>。虽然key通常用于计算注意力分数，value用于加权（权重矩阵）。但他们都来自相同的input，表示了相同token的内容信息。这里是假设 用于将 Key 向量中的内容信息压缩成低维表示的“知识”和用于压缩 Value 向量的“知识”是相似的，或者至少是可以通过同一个矩阵来学习的 。用来大幅提高效率。</li>
</ul>

<p><img src="/images/yuque/001-attention/image-2-2b3beea8.png" alt="" /></p>]]></content><author><name>niuteng5618</name></author><category term="大模型技术" /><category term="模型架构与基础" /><category term="Attention" /><category term="Attention" /><category term="MHA" /><category term="GQA" /><category term="MQA" /><category term="MLA" /><summary type="html"><![CDATA[注意力机制通过查询（Query, Q）、键（Key, K）和**值（Value, V）三个向量来完成。 Query (Q): 像一个“问题”，代表当前词想从其他词那里寻找什么信息。 Key (K): 像一个“索引”，代表每个词能提供什么信息。 Value (V): 像一个“答案”，代表每个词所包含的具体内容。 注意力机制的工作流程就是：用当前词的 Q 去匹配所有词的 K，找出最相关的词，然后根据相关性分数，对所有词的 V 进行加权求和，得到当前词的最终表示。 MHA &amp; GQA &amp; MQA &amp; MLA 对比学习 MHA: Multi-Head Attention (多头注意力) MHA 的做法是，将每个 token 的 Q、K、V 向量拆分成多个小份（即多个“头”），每个“头”独立地进行一次注意力计算。最后，把所有“头”的计算结果拼接起来，再经过一个线性层，得到最终的输出。 优势： 这种并行处理让模型能够同时捕捉到输入序列中各种不同类型、不同层面的信息，大大增强了模型的表达能力。 缺点： 在生成长文本时，每个 token 都会产生一套完整的 Q、K、V 向量，并且都需要被缓存（即 KV cache）。当序列长度增加时，KV cache 的大小会线性增长，导致占用大量的显存（GPU 内存），成为推理速度的瓶颈。 疑问解答 问题：输入数据是一致的，那么多个head是如何实现独立计算的？ 回答： 输入到多头注意力层的数据是相同的。但每个“头”都有自己独一无二的权重矩阵。 MHA会为每个头分配大小相同但随机初始化的权重矩阵，来实现独立的注意力计算。 在模型训练过程中，这些随机初始化的权重会通过反向传播和梯度下降算法进行调整。 **问题： **为什么多个头的结果要拼接起来，经过一个线性层？直接拼完输出不行吗？ 回答： 拼接的含义不是相加，更像是追加。因此多个head的结果拼在一起，可能会导致结果特别长，和下一层的维度不匹配。 拼接后使用Liner，可以重塑size，更好的对接下一层。 拼接后使用Liner，可以学习如何将拼接后的长向量重新压缩成一个紧凑的、包含了所有关键信息的新向量，供后续的层继续处理。 这个Liner更像是一个信息筛选、信息整合层。是至关重要的一层。 MQA: Multi-Query Attention (多查询注意力) 所有Q共享一组KV。 每个head对应一个query，所有head只有一对KV。 KV cache 大小大大减少，但整体效果较差。 GQA: Grouped-Query Attention (分组查询注意力) GQA 是一种介于 MHA 和 MQA 之间的折中方案 将 Q 向量分成 G 个组，然后为每个组生成一组 K 和 V 向量。当 G 等于 attention head 的数量时，GQA 就变成了 MHA；当 G 等于 1 时，GQA 就变成了 MQA。 许多现代大型语言模型（如 Llama 2、Mixtral）都采用了 GQA。 MLA: Multi-Head Latent Attention (多头潜在注意力) 【不太理解】 MLA 是 DeepSeek-V2 模型中引入的一种更新、更复杂的注意力机制。 MLA是MQA的低秩版本，通过将key和value压缩到latent空间存储，减少KV cache占用，并通过解压操作模拟多head注意力计算，性能不降低的同时减少了KVcache存储。 通俗解释： MLA 不再是简单地共享 K 和 V，而是通过一种“压缩和解压”的技术来优化。 在模型推理时，它不是直接存储完整的 K 和 V 向量，而是将它们压缩成一个更小、更紧凑的“潜在向量”（latent vector）并存储在 KV cache 中。 当需要计算注意力时，模型会从缓存中读取这个压缩的向量，并动态地“解压”成完整的 K 和 V 向量来使用。 优点：MLA 的主要优势在于极大地减小了 KV cache 的大小。因为它缓存的只是一个压缩后的向量，占用的显存比 MHA、MQA 和 GQA 都更少，这使得模型能够处理更长的上下文，特别是在需要处理超长序列时，优势非常明显。 关于MLA中的ROPE 添加旋转位置编码：旋转位置编码一般是添加在Q和K上的，但是ROPE与输入的具体位置有关，无法像前面MLA attention中的其他参数矩阵一样可以合并并提前计算。因此这里采用了一个折中的解决方案，即额外维护了另一组压缩参数矩阵用于维护位置信息。 query的压缩参数矩阵为WQR，key的压缩参数矩阵为WKR，其中R代表ROPE，指针对query和key分别进行压缩，用于旋转位置编码。 被压缩后的query和key分别应用于ROPE，并将应用过ROPE的query和key的对应向量拼接到原本解压后的query和value向量上，具体操作参见计算图。 被压缩后的query和key分别应用于ROPE，并将应用过ROPE的query和key的对应向量拼接到原本解压后的query和value向量上，具体操作参见计算图。 需要注意的是MLA中采取了MQA相同的设计，即不同head的query使用不同的位置编码后信息，不同head的key使用相同的位置编码后信息。这导致query和key的压缩矩阵维度不同，query的压缩维度是head数倍hidden size， key的压缩维度是1倍hidden size。 个人理解 MLA 主要是为了减少KVcache， 节省计算和显存 。 所谓压缩，就是添加一个Liner进行线性投影。解压也是同理，模型需要学习这个 压缩矩阵 和 解压矩阵。 这里提到MLA的RoPE与原始RoPE不同，主要是由于标准的RoPE需要在计算 注意力分数 之前，针对每个token 的位置对QK进行旋转，从而引入位置信息。但MLA对QK进行了压缩，所谓的token位置已经不存在了，因此无法直接使用标准RoPE引入位置信息。 MLA 的核心是把 Key 和 Value 向量压缩成一个“静态”的、与位置无关的潜在向量，并缓存起来。而 RoPE 是一种“动态”的位置编码，它的旋转操作依赖于每个 token 的具体位置索引。这两种机制在设计上是冲突的。 关于key和value使用同一个矩阵。虽然key通常用于计算注意力分数，value用于加权（权重矩阵）。但他们都来自相同的input，表示了相同token的内容信息。这里是假设 用于将 Key 向量中的内容信息压缩成低维表示的“知识”和用于压缩 Value 向量的“知识”是相似的，或者至少是可以通过同一个矩阵来学习的 。用来大幅提高效率。]]></summary></entry><entry><title type="html">FlashAttention</title><link href="https://niuteng5618.github.io/002-flashattention/" rel="alternate" type="text/html" title="FlashAttention" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>https://niuteng5618.github.io/002-flashattention</id><content type="html" xml:base="https://niuteng5618.github.io/002-flashattention/"><![CDATA[<p><img src="/images/yuque/002-flashattention/image-1-18516ce2.png" alt="" /></p>

<p>SRAM(静态随机存取存储器)</p>

<p>HBM(显存)</p>

<p><strong>FlashAttention算法核心思想</strong>：减少HBM(显存)的访问，将QKV切分为小块后放入SRAM中，计算完毕后_<strong>(矩阵乘法、mask、softmax、dropout)</strong>_，将计算结果从SRAM中写入到HBM中</p>

<p><strong>核心方法</strong>：tiling, recomputation</p>

<p><strong>1. tiling(平铺): 分块计算</strong></p>

<p>因为Attention计算中涉及Softmax，所以不能简单的分块后直接计算。softmax操作是row-wise的，即每行都算一次softmax，所以需要用到</p>

<p><a href="https://zhida.zhihu.com/search?content_id=238498031&amp;content_type=Article&amp;match_order=1&amp;q=%E5%B9%B3%E9%93%BA%E7%AE%97%E6%B3%95&amp;zhida_source=entity">平铺算法</a>来分块计算softmax。</p>

<p>【<strong>safe softmax</strong>】 原始softmax数值不稳定，为了数值稳定性，FlashAttention采用safe softmax。(也就是减去一个最大值再softmax)</p>

<p><strong>2 recomputation（重新计算）</strong></p>

<p>FlashAttention算法的目标：在计算中减少显存占用，从O(N²) 大小降低到线性，这样就可以把数据加载到SRAM中，提高IO速度。</p>

<p><strong>解决方案</strong>：传统Attention在计算中需要用到Q，K，V去计算S，P两个矩阵，FlashAttention引入softmax中的统计量(<em>m, l</em>)，结合output O和在SRAM中的Q，K，V块进行计算。</p>]]></content><author><name>niuteng5618</name></author><category term="大模型技术" /><category term="模型架构与基础" /><category term="Attention" /><category term="Attention" /><category term="Flash Attention" /><category term="Tiling" /><category term="SRAM" /><category term="HBM" /><summary type="html"><![CDATA[SRAM(静态随机存取存储器) HBM(显存) FlashAttention算法核心思想：减少HBM(显存)的访问，将QKV切分为小块后放入SRAM中，计算完毕后_(矩阵乘法、mask、softmax、dropout)_，将计算结果从SRAM中写入到HBM中 核心方法：tiling, recomputation 1. tiling(平铺): 分块计算 因为Attention计算中涉及Softmax，所以不能简单的分块后直接计算。softmax操作是row-wise的，即每行都算一次softmax，所以需要用到 平铺算法来分块计算softmax。 【safe softmax】 原始softmax数值不稳定，为了数值稳定性，FlashAttention采用safe softmax。(也就是减去一个最大值再softmax) 2 recomputation（重新计算） FlashAttention算法的目标：在计算中减少显存占用，从O(N²) 大小降低到线性，这样就可以把数据加载到SRAM中，提高IO速度。 解决方案：传统Attention在计算中需要用到Q，K，V去计算S，P两个矩阵，FlashAttention引入softmax中的统计量(m, l)，结合output O和在SRAM中的Q，K，V块进行计算。]]></summary></entry><entry><title type="html">encoder-only容易退化低秩</title><link href="https://niuteng5618.github.io/003-encoder-only/" rel="alternate" type="text/html" title="encoder-only容易退化低秩" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>https://niuteng5618.github.io/003-encoder-only</id><content type="html" xml:base="https://niuteng5618.github.io/003-encoder-only/"><![CDATA[<p>相较于decoder-only，encoder-only在训练过程中更容易退化为低秩。</p>

<ul>
  <li><strong>低秩：</strong> 现在假设你的表格有 5 列，但其中有 3 列的信息是重复的，或者可以由另外两列推算出来。比如，一列是“身高（cm）”，另一列是“身高（inch）”，这两列信息是重复的。如果你的表格只有 2 列是独立的，另外 3 列都是冗余的，那么这个表格就是“低秩”的。低秩代表着<strong>信息有冗余、不丰富</strong>。</li>
  <li>满<strong>秩：</strong> 假设你有一个表格，有 5 列数据，每一列都包含了独一无二、无法从其他列推导出来的信息。比如，一列是“身高”，一列是“体重”，一列是“年龄”，这三列信息是独立的。如果你的表格有 5 列，而且这 5 列都是完全独立的，那么这个表格就是满秩的。满秩代表着<strong>信息丰富、没有冗余</strong>。</li>
</ul>

<h3 id="退化为低秩的原因">退化为低秩的原因</h3>
<ul>
  <li>出现低秩的原因，主要是由于encoder-only采用<strong>双向注意力，</strong>而<strong>decoder-only</strong>采用<strong>causal注意力</strong>是单向的。</li>
  <li><strong>Encoder-only 的双向注意力</strong>：每个 token 都可以看到所有 token。这听起来很好，但有时会过犹不及。就像在一个会议上，每个人都可以自由发言，但如果大家都在说一些重复、没有新意的话，整个会议的讨论质量就会下降。</li>
  <li><strong>模型退化</strong>：在训练过程中，模型为了找到一个简单的、能快速收敛的解，可能会让注意力矩阵中的大部分行（即 token 的注意力分布）变得非常相似，只关注了几个“重要”的 token。这样一来，虽然模型能完成任务，但它的<strong>信息表达变得非常单调</strong>，丢失了捕捉细微差别的能力。这就会导致<strong>矩阵“退化”成了“低秩”</strong>。</li>
</ul>

<h3 id="个人理解">个人理解</h3>
<ul>
  <li>低秩肯定是不好的，容易导致模型学不到真正有用的东西。</li>
  <li>当一个模型或矩阵<strong>退化为低秩</strong>，就是指它原本应该具有的丰富、多样的信息表达能力，<strong>在训练过程中逐渐减弱，变得单一、僵化。</strong></li>
  <li>模型在训练的过程中，为了尽快达到最优值（根据优化目标而定），可能在不断迭代训练中发现，只关注某几个token就能达到最好的效果，这属于模型走”捷径”。但我们不希望训练的模型，采用这种方式。这种方式会忽视完整的上下文。</li>
  <li>双向注意力关注的范围更广，发现“某几个重要token”的机会也就更多。因此 双向注意力要比单向注意力更容易出现低秩退化的问题。</li>
  <li>退化的主要是注意力权重矩阵。</li>
</ul>

<p>###</p>]]></content><author><name>niuteng5618</name></author><category term="大模型技术" /><category term="模型架构与基础" /><category term="Transformer 架构" /><category term="Transformer 架构" /><category term="Encoder-only" /><category term="Decoder-only" /><category term="低秩退化" /><category term="注意力矩阵" /><summary type="html"><![CDATA[相较于decoder-only，encoder-only在训练过程中更容易退化为低秩。 低秩： 现在假设你的表格有 5 列，但其中有 3 列的信息是重复的，或者可以由另外两列推算出来。比如，一列是“身高（cm）”，另一列是“身高（inch）”，这两列信息是重复的。如果你的表格只有 2 列是独立的，另外 3 列都是冗余的，那么这个表格就是“低秩”的。低秩代表着信息有冗余、不丰富。 满秩： 假设你有一个表格，有 5 列数据，每一列都包含了独一无二、无法从其他列推导出来的信息。比如，一列是“身高”，一列是“体重”，一列是“年龄”，这三列信息是独立的。如果你的表格有 5 列，而且这 5 列都是完全独立的，那么这个表格就是满秩的。满秩代表着信息丰富、没有冗余。 退化为低秩的原因 出现低秩的原因，主要是由于encoder-only采用双向注意力，而decoder-only采用causal注意力是单向的。 Encoder-only 的双向注意力：每个 token 都可以看到所有 token。这听起来很好，但有时会过犹不及。就像在一个会议上，每个人都可以自由发言，但如果大家都在说一些重复、没有新意的话，整个会议的讨论质量就会下降。 模型退化：在训练过程中，模型为了找到一个简单的、能快速收敛的解，可能会让注意力矩阵中的大部分行（即 token 的注意力分布）变得非常相似，只关注了几个“重要”的 token。这样一来，虽然模型能完成任务，但它的信息表达变得非常单调，丢失了捕捉细微差别的能力。这就会导致矩阵“退化”成了“低秩”。 个人理解 低秩肯定是不好的，容易导致模型学不到真正有用的东西。 当一个模型或矩阵退化为低秩，就是指它原本应该具有的丰富、多样的信息表达能力，在训练过程中逐渐减弱，变得单一、僵化。 模型在训练的过程中，为了尽快达到最优值（根据优化目标而定），可能在不断迭代训练中发现，只关注某几个token就能达到最好的效果，这属于模型走”捷径”。但我们不希望训练的模型，采用这种方式。这种方式会忽视完整的上下文。 双向注意力关注的范围更广，发现“某几个重要token”的机会也就更多。因此 双向注意力要比单向注意力更容易出现低秩退化的问题。 退化的主要是注意力权重矩阵。 ###]]></summary></entry><entry><title type="html">LLaMA基础</title><link href="https://niuteng5618.github.io/004-llama/" rel="alternate" type="text/html" title="LLaMA基础" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>https://niuteng5618.github.io/004-llama</id><content type="html" xml:base="https://niuteng5618.github.io/004-llama/"><![CDATA[<p>推理流程</p>

<p><strong>prompt</strong>:输入的一段文本</p>

<p><strong>tokenization</strong>:将文本进行tokenization,切分为单词或字符，形成token序列。</p>

<p>序列化-&gt;</p>

<p>[‘BOS’,’君’,’不’,’见’,’黄’,’河’,’之’,’水’,’天’,’上’,’来’,’，’ ,’奔’,’流’,’到’…‘与’,’尔’,’同’,’销’,’万’,’古’,’愁’,’EOS’]</p>

<p>假设语料库索引化-&gt;</p>

<p>[‘BOS’,’10’,’3’,’67’,’89’,’21’,’45’,’55’,’61’,’4’,’324’,’565’ ,’789’,’6567’,’786’…‘7869’,’9’,’3452’,’563’,’56’,’66’,’77’,’EOS’]</p>

<p><strong>embedding</strong>：文本经过tokenization后变为了token序列，embedding将每个token映射为实数向量，名为embedding vector</p>

<p>‘BOS’-&gt; [p_{00},p_{01},p_{02},…,p_{0d-1}]</p>

<p>‘10’ -&gt; [p_{10},p_{11},p_{12},…,p_{1d-1}]</p>

<p>‘3’  -&gt; [p_{20},p_{21},p_{22},…,p_{2d-1}]</p>

<p>…</p>

<p>‘EOS’-&gt; [p_{n0},p_{n1},p_{n2},…,p_{nd-1}]</p>

<p><strong>位置编码</strong>：对于Token序列中的每个位置，添加位置编码（Positional Encoding）向量，以提供关于Token在序列中位置的信息。位置编码是为了区分不同位置的Token，并为模型提供上下文关系的信息。</p>

<p><strong>自回归生成</strong>：在生成任务中，使用自回归（Autoregressive）方式，即逐个生成输出序列中的每个Token。在解码过程中，每次生成一个Token时，使用前面已生成的内容作为上下文，来帮助预测下一个Token。很多模型都是自回归模型</p>

<p>LLama模型结构</p>

<p><img src="/images/yuque/004-llama/image-1-96583f7b.png" alt="" /></p>

<p>LLama用了32个transformer块，与标准transformer的区别如下：</p>

<p>前置的<strong>RMSNorm</strong></p>

<p>Q在与K相乘之前，先使用<strong>RoPEKV Cache，Group Query Attention(GQA)</strong></p>

<p>FeedForward层</p>

<p><strong>RMSNorm</strong></p>

<p>LLama模型首先经过embedding层，然后依次经过32个transformer。</p>

<p>其中，LLama的transformer没有使用LayerNorm(标准的transformer)进行tensor的归一化，而是RMSNorm。</p>

<p><a href="https://link.zhihu.com/?target=https%3A//arxiv.org/pdf/1910.07467.pdf">RMSNorm</a>是LayerNorm的变体，RMSNorm省去了求均值的过程,也没有了偏置β，</p>

<p>RMSNorm在分子上移除了均值项，这点在论文里面有实验解释re-center的操作没有很重要</p>

<p>RMSNorm仅使用平方根的均值，与使用方差相比，可以降低噪声的影响</p>

<p>（个人认为最重要的）简化了LayerNorm, 相比较以前均值和方差的计算量，现在只需要计算RMS, 大大减少了计算时间（7%-64%）作为开源模型，使用者没有那么强的算力，因此减少cost能为开源项目带来更多的优势</p>

<table>
  <thead>
    <tr>
      <th>LayerNorm公式</th>
      <th><br />RMSNorm的公式<br /></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><br /><img src="/images/yuque/004-llama/image-2-ee32bea4.png" alt="" /><br /></td>
      <td><br /><img src="/images/yuque/004-llama/image-3-80b2d3cd.png" alt="" /></td>
    </tr>
  </tbody>
</table>

<p><img src="/images/yuque/004-llama/image-4-044acf13.png" alt="" /></p>

<p>notes:</p>

<p>在进行Norm时，以某行向量为单位，E(x)是这一行的均值，Var(x)是这一行的方差(RMSNorm中没有这两项了)。</p>

<p>Norm层输入shape：[batch_size, seq_len, hidden_dim]，输出不变，不会改变，只是做一个归一化。</p>

<p><strong>Attention</strong></p>

<p>下图为了便于理解，只是单头注意力，实际是多头，是多个单头并行。</p>

<p><img src="/images/yuque/004-llama/image-5-873ea11a.png" alt="" /></p>

<p>注意力机制输入shape：[batch_size, seq_len, hidden_dim]，和Norm归一化的输出一致。</p>

<p>Linear层的weight: [hidden_dim, hidden_dim]</p>

<p>也就是 tensor 与 Linear的weight做乘法(这里是单batch_size，实际要考虑多batch_size)。</p>

<p><strong>Linear层输出：</strong></p>

<p>单batch_size时，linear的处理为:</p>

<p>[seq_len, hidden_dim] × [ hidden_dim, hidden_dim ] = [seq_len, hidden_dim]</p>

<p>多batch_size时，结果为：</p>

<p>[batch_size, seq_len, hidden_dim]</p>

<p><strong>怎么得到QKV呢？</strong></p>

<p>将输出与3个Linear层wq、wk、wv作乘法，分别得到Q、K、V</p>

<p>并不会改变原始的[batch_size, seq_len, hidden_dim]，只是做权重乘法。</p>

<p><img src="/images/yuque/004-llama/image-6-547fb56e.png" alt="" /></p>

<p>这里做了一个view，也就是reshape。得到的输出为</p>

<p>[batch_size, seq_len, n_local_heads, head_dim]</p>

<p>#n_local_heads 为多头注意力的头数，实际为多个单头并行。</p>

<p><img src="/images/yuque/004-llama/image-7-bc0428b2.png" alt="" /></p>

<p><strong>attention计算</strong></p>

<p>QKV的格式是一样的，都是[batch_size, seq_len, head_dim]</p>

<p><img src="/images/yuque/004-llama/image-8-b4892d9a.png" alt="" /></p>

<p><img src="/images/yuque/004-llama/image-9-c040b47e.png" alt="" /></p>

<p><strong>Q · K转置(后面还要再乘一个Mask矩阵，在下面)</strong></p>

<p><img src="/images/yuque/004-llama/image-10-65f9f3f4.png" alt="" /></p>

<p><strong>再 乘 V</strong></p>

<p><img src="/images/yuque/004-llama/image-11-0180b515.png" alt="" /></p>

<p><strong>Mask</strong></p>

<p>在Q·K转置后，加一个矩阵。</p>

<p><img src="/images/yuque/004-llama/image-12-f2b5f335.png" alt="" /></p>

<p>在NLP中，预测时，前面的字不能知道后面的字，</p>

<p>“君不见黄河之水”，“君”不能知道他后面的字，只能知道前面的字，因此需要 掩住，也就是降低其注意力(关注程度)，因此可以下面的掩码矩阵进行相加。</p>

<p>右上角是负无穷(-inf)，左下角是0。</p>

<p><img src="/images/yuque/004-llama/image-13-aee1d319.png" alt="" /></p>

<p>距离较远的2个字的向量，例如Q(君) × K转置(河)的结果，会与下图红色框的数字继续相加。得到的结果会是一个负无穷(-inf)。</p>

<p>距离较近的2个字的向量，会与左下角的0相加，不会有任何变化。</p>

<p><img src="/images/yuque/004-llama/image-14-a4a06383.png" alt="" /></p>

<p>随后再进行softmax，公式如下：</p>

<p><img src="/images/yuque/004-llama/image-15-6c6aa581.png" alt="" /></p>

<p>e的负无穷次幂，结果是0，这就可以将得到的Q·K转置结果的右上角置为0。</p>

<p>其他不为负无穷(-inf)的地方,softmax结果还是之前的数值(因为加的是左下角的0，任何数+0还等于原数)。</p>

<p>最终经过Mask的结果可能是下图，随后与V相乘：</p>

<p><img src="/images/yuque/004-llama/image-16-0909ccfd.png" alt="" /></p>

<p><strong>KV cache</strong></p>

<p>假设“将进酒：人生得” 来预测下一个”意”字，所以需要把<strong>“将进酒：人生得”</strong>进行token化后再进行Attention计算，如下图。</p>

<p><img src="/images/yuque/004-llama/image-17-f123bfca.png" alt="" /></p>

<p>但是，其实已经把<strong>“将进酒：人生”</strong>所对应的Q,K,V进行过相关的运算，所以没必要在对他们进行Attention计算。</p>

<p>KV Cache便是来解决这个问题的：通过将每次计算的K和V缓存下来，之后新的序列进来时只需要从KV Cache中读取之前的KV值即可，就不需要再去重复计算之前的KV了。</p>

<p>对于Q，也不需要将所有Q都计算，只计算最新的Q_newtoken即可。如下图。只计算Q_newtoken与 K_newtoken 的乘积(Q_oldtoken计算完就不要了，K_oldtokens和V_oldtokens缓存起来)。</p>

<p><img src="/images/yuque/004-llama/image-18-6f14ccbe.png" alt="" /></p>

<p>然后将结果与 V_newtoken 进行乘积。</p>

<p><img src="/images/yuque/004-llama/image-19-0b5f61f7.png" alt="" /></p>

<p>解释1：至于为什么不用缓存Q？ 我理解这是一种单向注意力机制，他只管每次进来的token与past tokens的注意力，而past tokens不会管后面token的注意力，所以就不需要Q_past_tokens,也就不需要缓存Q</p>

<p>解释2：Q表示当前步查询的词向量，K、V表示之前生成的词的表示。K类似储存了哪些信息，Q在K中进行相似度匹配，随后与V加权生成关注程度。从这个角度理解的话，Q是需要每一步都要动态计算的，无法缓存。因此可以进行kvcache，每一步只计算Q_newtoken。</p>

<p>解释3：这是因为Q通常只涉及当前时间步的数据，因此无需缓存。而K和V代表了上下文信息，它们在未来的每一个时间步都可能被用来与新的Q进行匹配。缓存K和V允许模型在处理长序列时避免重复计算，提升推理效率。</p>

<p><strong>为什么可以进行cache，从GPU结构角度理解。</strong></p>

<p>越靠近GPU的ALU(逻辑运算单元)，速度越快</p>

<p>越远离ALU，内存越大</p>

<p>一般(SRAM)只有几十M的大小，7B的模型处理4096序列长度就需要512M的缓存空间了，更何况130B，所以cache一般存储在global memory(显存)中。</p>

<p>但是显存有一个明显的缺点，运算慢，这会导致一个问题<strong>Memory wall(内存墙)。</strong>简单来说就是处理器ALU太快，单内存读写速度慢跟不上，导致ALU算完在那等着你把数据读进来，影响性能。</p>

<p><img src="/images/yuque/004-llama/image-20-dcd9104a.png" alt="" /></p>

<p><strong>解决Memory Wall</strong>(内存墙)的方法：</p>

<p><strong>硬件层面，</strong>使用<strong>高速带宽内容(HBM)</strong>来提高读取速度，或者抛弃冯诺依曼架构，改变计算单元从内存读数据的方式，不再以计算单元为中心，而是以存储为中心，做成计算和存储一体化的”存内计算”，比如 <strong>忆阻器</strong>。</p>

<p><strong>软件层面，</strong>引入<strong>优化算法</strong>，例如<strong>GQA</strong>。</p>

<p><strong>GQA</strong></p>

<p><img src="/images/yuque/004-llama/image-21-51312dc1.png" alt="" /></p>

<p>GQA是一种介于Multi-head和Multi-query(MQA)之间的形式。每2个（一组）head共享一个K、V。</p>

<p>MQA保留多头，但所有head的Q共享1个K、V，精度低。</p>

<p><strong>绝对位置编码为什么要位置编码：</strong>模型处理输入数据是不知道词的先后顺序，需要通过位置信息补充这一信息，如果没有位置编码“我和你”、“你和我”、“你我和”都是一样的。</p>

<p>在标准的Transformer中通常是在整个网络进入Transformer Block之前做一个位置编码，如下图所示</p>

<p><img src="/images/yuque/004-llama/image-22-b545d0b3.png" alt="" /></p>

<p><img src="/images/yuque/004-llama/image-23-bb8c949b.png" alt="" /></p>

<p><img src="/images/yuque/004-llama/image-24-f9155571.png" alt="" /></p>

<p><strong>RoPE</strong></p>

<p>通过绝对位置编码方式实现相对位置编码。</p>

<p>假设给q、k添加绝对位置信息，如下。</p>

<p><img src="/images/yuque/004-llama/image-25-f1a6b983.png" alt="" /></p>

<p>上述的q-、k-带有了m、n的绝对位置信息。之后在进行attention的计算时，需要计算q-、k-的内积。</p>

<p>但为了获取相对位置信息，需要使得内积的结果受m-n的影响即可。也就是说要满足如下：</p>

<p><img src="/images/yuque/004-llama/image-26-10d47c72.png" alt="" /></p>

<p><img src="/images/yuque/004-llama/image-27-91e236f3.png" alt="" /></p>

<p><img src="/images/yuque/004-llama/image-28-4f071978.png" alt="" /></p>

<p>总结来说，RoPE 的 self-attention 操作的流程是：对于 token 序列中的每个词嵌入向量，首先计算其对应的 query 和 key 向量，然后对每个 token 位置都计算对应的旋转位置编码。</p>

<p>接着对每个 token 位置的 query 和 key 向量的元素按照 <strong>两两一组</strong> 应用旋转变换（见上面一个公式），最后再计算 query 和 key 之间的内积得到 self-attention 的计算结果（用qk变换后的结果计算内积）。</p>

<p>下面是论文原图。</p>

<p><img src="/images/yuque/004-llama/image-29-6cb52b2b.png" alt="" /></p>

<p><strong>关于RoPE中的θ</strong></p>

<p><img src="/images/yuque/004-llama/image-30-74ce07ba.png" alt="" /></p>

<p><img src="/images/yuque/004-llama/image-31-94b5d7d5.png" alt="" /></p>

<p>为什么这么设置？因为 <strong>远程衰减特性。</strong>向量k在q附近，注意力分数偏高、反之偏低</p>

<p><img src="/images/yuque/004-llama/image-32-37e008ce.png" alt="" /></p>

<p><strong>Feedforward Neural Network（前馈神经网络，FNN）</strong></p>

<p><img src="/images/yuque/004-llama/image-33-a7360bc9.png" alt="" /></p>

<p><strong>SiLU</strong></p>

<p>与标准的Transformer一样，经过Attention层之后就进行FeedForward层的处理，但LLama2的FeedForward与标准的Transformer FeedForward有一些细微的差异,需要注意的地方就是SiLU</p>

<p><img src="/images/yuque/004-llama/image-34-0991ccd9.png" alt="" /></p>]]></content><author><name>niuteng5618</name></author><category term="大模型技术" /><category term="模型架构与基础" /><category term="LLaMA" /><category term="LLaMA" /><category term="Tokenization" /><category term="Embedding" /><category term="Transformer" /><summary type="html"><![CDATA[推理流程 prompt:输入的一段文本 tokenization:将文本进行tokenization,切分为单词或字符，形成token序列。 序列化-&gt; [‘BOS’,’君’,’不’,’见’,’黄’,’河’,’之’,’水’,’天’,’上’,’来’,’，’ ,’奔’,’流’,’到’…‘与’,’尔’,’同’,’销’,’万’,’古’,’愁’,’EOS’] 假设语料库索引化-&gt; [‘BOS’,’10’,’3’,’67’,’89’,’21’,’45’,’55’,’61’,’4’,’324’,’565’ ,’789’,’6567’,’786’…‘7869’,’9’,’3452’,’563’,’56’,’66’,’77’,’EOS’] embedding：文本经过tokenization后变为了token序列，embedding将每个token映射为实数向量，名为embedding vector ‘BOS’-&gt; [p_{00},p_{01},p_{02},…,p_{0d-1}] ‘10’ -&gt; [p_{10},p_{11},p_{12},…,p_{1d-1}] ‘3’ -&gt; [p_{20},p_{21},p_{22},…,p_{2d-1}] … ‘EOS’-&gt; [p_{n0},p_{n1},p_{n2},…,p_{nd-1}] 位置编码：对于Token序列中的每个位置，添加位置编码（Positional Encoding）向量，以提供关于Token在序列中位置的信息。位置编码是为了区分不同位置的Token，并为模型提供上下文关系的信息。 自回归生成：在生成任务中，使用自回归（Autoregressive）方式，即逐个生成输出序列中的每个Token。在解码过程中，每次生成一个Token时，使用前面已生成的内容作为上下文，来帮助预测下一个Token。很多模型都是自回归模型 LLama模型结构 LLama用了32个transformer块，与标准transformer的区别如下： 前置的RMSNorm Q在与K相乘之前，先使用RoPEKV Cache，Group Query Attention(GQA) FeedForward层 RMSNorm LLama模型首先经过embedding层，然后依次经过32个transformer。 其中，LLama的transformer没有使用LayerNorm(标准的transformer)进行tensor的归一化，而是RMSNorm。 RMSNorm是LayerNorm的变体，RMSNorm省去了求均值的过程,也没有了偏置β， RMSNorm在分子上移除了均值项，这点在论文里面有实验解释re-center的操作没有很重要 RMSNorm仅使用平方根的均值，与使用方差相比，可以降低噪声的影响 （个人认为最重要的）简化了LayerNorm, 相比较以前均值和方差的计算量，现在只需要计算RMS, 大大减少了计算时间（7%-64%）作为开源模型，使用者没有那么强的算力，因此减少cost能为开源项目带来更多的优势 LayerNorm公式 RMSNorm的公式 notes: 在进行Norm时，以某行向量为单位，E(x)是这一行的均值，Var(x)是这一行的方差(RMSNorm中没有这两项了)。 Norm层输入shape：[batch_size, seq_len, hidden_dim]，输出不变，不会改变，只是做一个归一化。 Attention 下图为了便于理解，只是单头注意力，实际是多头，是多个单头并行。 注意力机制输入shape：[batch_size, seq_len, hidden_dim]，和Norm归一化的输出一致。 Linear层的weight: [hidden_dim, hidden_dim] 也就是 tensor 与 Linear的weight做乘法(这里是单batch_size，实际要考虑多batch_size)。 Linear层输出： 单batch_size时，linear的处理为: [seq_len, hidden_dim] × [ hidden_dim, hidden_dim ] = [seq_len, hidden_dim] 多batch_size时，结果为： [batch_size, seq_len, hidden_dim] 怎么得到QKV呢？ 将输出与3个Linear层wq、wk、wv作乘法，分别得到Q、K、V 并不会改变原始的[batch_size, seq_len, hidden_dim]，只是做权重乘法。 这里做了一个view，也就是reshape。得到的输出为 [batch_size, seq_len, n_local_heads, head_dim] #n_local_heads 为多头注意力的头数，实际为多个单头并行。 attention计算 QKV的格式是一样的，都是[batch_size, seq_len, head_dim] Q · K转置(后面还要再乘一个Mask矩阵，在下面) 再 乘 V Mask 在Q·K转置后，加一个矩阵。 在NLP中，预测时，前面的字不能知道后面的字， “君不见黄河之水”，“君”不能知道他后面的字，只能知道前面的字，因此需要 掩住，也就是降低其注意力(关注程度)，因此可以下面的掩码矩阵进行相加。 右上角是负无穷(-inf)，左下角是0。 距离较远的2个字的向量，例如Q(君) × K转置(河)的结果，会与下图红色框的数字继续相加。得到的结果会是一个负无穷(-inf)。 距离较近的2个字的向量，会与左下角的0相加，不会有任何变化。 随后再进行softmax，公式如下： e的负无穷次幂，结果是0，这就可以将得到的Q·K转置结果的右上角置为0。 其他不为负无穷(-inf)的地方,softmax结果还是之前的数值(因为加的是左下角的0，任何数+0还等于原数)。 最终经过Mask的结果可能是下图，随后与V相乘： KV cache 假设“将进酒：人生得” 来预测下一个”意”字，所以需要把“将进酒：人生得”进行token化后再进行Attention计算，如下图。 但是，其实已经把“将进酒：人生”所对应的Q,K,V进行过相关的运算，所以没必要在对他们进行Attention计算。 KV Cache便是来解决这个问题的：通过将每次计算的K和V缓存下来，之后新的序列进来时只需要从KV Cache中读取之前的KV值即可，就不需要再去重复计算之前的KV了。 对于Q，也不需要将所有Q都计算，只计算最新的Q_newtoken即可。如下图。只计算Q_newtoken与 K_newtoken 的乘积(Q_oldtoken计算完就不要了，K_oldtokens和V_oldtokens缓存起来)。 然后将结果与 V_newtoken 进行乘积。 解释1：至于为什么不用缓存Q？ 我理解这是一种单向注意力机制，他只管每次进来的token与past tokens的注意力，而past tokens不会管后面token的注意力，所以就不需要Q_past_tokens,也就不需要缓存Q 解释2：Q表示当前步查询的词向量，K、V表示之前生成的词的表示。K类似储存了哪些信息，Q在K中进行相似度匹配，随后与V加权生成关注程度。从这个角度理解的话，Q是需要每一步都要动态计算的，无法缓存。因此可以进行kvcache，每一步只计算Q_newtoken。 解释3：这是因为Q通常只涉及当前时间步的数据，因此无需缓存。而K和V代表了上下文信息，它们在未来的每一个时间步都可能被用来与新的Q进行匹配。缓存K和V允许模型在处理长序列时避免重复计算，提升推理效率。 为什么可以进行cache，从GPU结构角度理解。 越靠近GPU的ALU(逻辑运算单元)，速度越快 越远离ALU，内存越大 一般(SRAM)只有几十M的大小，7B的模型处理4096序列长度就需要512M的缓存空间了，更何况130B，所以cache一般存储在global memory(显存)中。 但是显存有一个明显的缺点，运算慢，这会导致一个问题Memory wall(内存墙)。简单来说就是处理器ALU太快，单内存读写速度慢跟不上，导致ALU算完在那等着你把数据读进来，影响性能。 解决Memory Wall(内存墙)的方法： 硬件层面，使用高速带宽内容(HBM)来提高读取速度，或者抛弃冯诺依曼架构，改变计算单元从内存读数据的方式，不再以计算单元为中心，而是以存储为中心，做成计算和存储一体化的”存内计算”，比如 忆阻器。 软件层面，引入优化算法，例如GQA。 GQA GQA是一种介于Multi-head和Multi-query(MQA)之间的形式。每2个（一组）head共享一个K、V。 MQA保留多头，但所有head的Q共享1个K、V，精度低。 绝对位置编码为什么要位置编码：模型处理输入数据是不知道词的先后顺序，需要通过位置信息补充这一信息，如果没有位置编码“我和你”、“你和我”、“你我和”都是一样的。 在标准的Transformer中通常是在整个网络进入Transformer Block之前做一个位置编码，如下图所示 RoPE 通过绝对位置编码方式实现相对位置编码。 假设给q、k添加绝对位置信息，如下。 上述的q-、k-带有了m、n的绝对位置信息。之后在进行attention的计算时，需要计算q-、k-的内积。 但为了获取相对位置信息，需要使得内积的结果受m-n的影响即可。也就是说要满足如下： 总结来说，RoPE 的 self-attention 操作的流程是：对于 token 序列中的每个词嵌入向量，首先计算其对应的 query 和 key 向量，然后对每个 token 位置都计算对应的旋转位置编码。 接着对每个 token 位置的 query 和 key 向量的元素按照 两两一组 应用旋转变换（见上面一个公式），最后再计算 query 和 key 之间的内积得到 self-attention 的计算结果（用qk变换后的结果计算内积）。 下面是论文原图。 关于RoPE中的θ 为什么这么设置？因为 远程衰减特性。向量k在q附近，注意力分数偏高、反之偏低 Feedforward Neural Network（前馈神经网络，FNN） SiLU 与标准的Transformer一样，经过Attention层之后就进行FeedForward层的处理，但LLama2的FeedForward与标准的Transformer FeedForward有一些细微的差异,需要注意的地方就是SiLU]]></summary></entry><entry><title type="html">Query 重写与增强</title><link href="https://niuteng5618.github.io/005-query/" rel="alternate" type="text/html" title="Query 重写与增强" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>https://niuteng5618.github.io/005-query</id><content type="html" xml:base="https://niuteng5618.github.io/005-query/"><![CDATA[<p><strong>最简单方法：Query 直接变 Embedding是最理想化、也是效果最差的实现方式</strong>。</p>

<p><strong>痛点：用户输入的 Query 往往是碎片化、含糊不清且具有极强误导性的。</strong> 直接转向量（Dense Retrieval）容易产生“语义漂移”，而单纯依赖<strong>关键词</strong>（Sparse Retrieval）又会<strong>错失同义词</strong>。</p>

<h3 id="query-重写与增强-pipeline">Query 重写与增强 Pipeline</h3>
<h4 id="第一阶段预处理与清洗-sanitization">第一阶段：预处理与清洗 (Sanitization)</h4>
<ul>
  <li><strong>纠错 (Spell Check)</strong>：用户打错字（如“深度学系”-&gt;“深度学习”）会导致 Embedding 偏移。</li>
  <li><strong>敏感词过滤 (Safety Guardrail)</strong>：拦截非法请求。</li>
  <li><strong>去停用词/分词</strong>：针对传统关键词检索的优化。</li>
</ul>

<h4 id="第二阶段query-变换-query-transformation--核心步骤">第二阶段：Query 变换 (Query Transformation) —— 核心步骤</h4>
<p>这是解决“语义差距”最有效的手段：</p>

<ul>
  <li><strong>Query 扩展 (Query Expansion)</strong>：利用 LLM 生成原问题的 <strong>3-5 个同义改写版</strong>本。这样可以从多个角度覆盖向量空间，提高召回率。</li>
  <li><strong>假设性文档嵌入 (HyDE)</strong>：
    <ul>
      <li><strong>逻辑</strong>：让 LLM 先写一个“伪答案”，然后用<strong>伪答案的向量</strong>去知识库搜真实文档。</li>
      <li><strong>理由</strong>：Query 和 Document 之间存在“不对称性”（问题很短，答案很长），Query 搜答案很难，但“伪答案”搜“真答案”在语义上更接近。</li>
    </ul>
  </li>
  <li><strong>Query 压缩与重写 (Rewriting)</strong>：在多轮对话中，用户说“那它支持什么？”，LLM 需要将其重写为“Qwen3-14B 模型支持哪些工具调用？”。</li>
</ul>

<h4 id="第三阶段多路路由-query-routing">第三阶段：多路路由 (Query Routing)</h4>
<ul>
  <li><strong>意图识别</strong>：判断 Query 是要“查知识库”、还是“闲聊”、或是“执行动作（如计算器）”。</li>
  <li><strong>元数据过滤 (Self-Querying)</strong>：如果 Query 是“2023年关于华为的财报”，系统应自动提取出 <code class="language-plaintext highlighter-rouge">{"year": 2023, "subject": "Huawei"}</code>，并在数据库中进行 Metadata Filter，而不是全量语义检索。</li>
</ul>

<h4 id="第四阶段多路召回与融合-hybrid-search">第四阶段：多路召回与融合 (Hybrid Search)</h4>
<ul>
  <li><strong>向量检索 (Vector)</strong> + <strong>全文检索 (BM25)</strong>。</li>
  <li><strong>RRF (Reciprocal Rank Fusion)</strong>：将两者的结果按排名加权合并。</li>
</ul>]]></content><author><name>niuteng5618</name></author><category term="RAG" /><category term="检索与排序" /><category term="Query 改写" /><category term="Query 改写" /><category term="Query 扩展" /><category term="HyDE" /><category term="检索增强" /><summary type="html"><![CDATA[最简单方法：Query 直接变 Embedding是最理想化、也是效果最差的实现方式。 痛点：用户输入的 Query 往往是碎片化、含糊不清且具有极强误导性的。 直接转向量（Dense Retrieval）容易产生“语义漂移”，而单纯依赖关键词（Sparse Retrieval）又会错失同义词。 Query 重写与增强 Pipeline 第一阶段：预处理与清洗 (Sanitization) 纠错 (Spell Check)：用户打错字（如“深度学系”-&gt;“深度学习”）会导致 Embedding 偏移。 敏感词过滤 (Safety Guardrail)：拦截非法请求。 去停用词/分词：针对传统关键词检索的优化。 第二阶段：Query 变换 (Query Transformation) —— 核心步骤 这是解决“语义差距”最有效的手段： Query 扩展 (Query Expansion)：利用 LLM 生成原问题的 3-5 个同义改写版本。这样可以从多个角度覆盖向量空间，提高召回率。 假设性文档嵌入 (HyDE)： 逻辑：让 LLM 先写一个“伪答案”，然后用伪答案的向量去知识库搜真实文档。 理由：Query 和 Document 之间存在“不对称性”（问题很短，答案很长），Query 搜答案很难，但“伪答案”搜“真答案”在语义上更接近。 Query 压缩与重写 (Rewriting)：在多轮对话中，用户说“那它支持什么？”，LLM 需要将其重写为“Qwen3-14B 模型支持哪些工具调用？”。 第三阶段：多路路由 (Query Routing) 意图识别：判断 Query 是要“查知识库”、还是“闲聊”、或是“执行动作（如计算器）”。 元数据过滤 (Self-Querying)：如果 Query 是“2023年关于华为的财报”，系统应自动提取出 {"year": 2023, "subject": "Huawei"}，并在数据库中进行 Metadata Filter，而不是全量语义检索。 第四阶段：多路召回与融合 (Hybrid Search) 向量检索 (Vector) + 全文检索 (BM25)。 RRF (Reciprocal Rank Fusion)：将两者的结果按排名加权合并。]]></summary></entry><entry><title type="html">pretrain基础-来源公众号</title><link href="https://niuteng5618.github.io/006-pretrain/" rel="alternate" type="text/html" title="pretrain基础-来源公众号" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>https://niuteng5618.github.io/006-pretrain</id><content type="html" xml:base="https://niuteng5618.github.io/006-pretrain/"><![CDATA[<p>开源数据集：FineWeb、pile、Skypile、RedPajama，但开源的肯定多少有不干净的数据。</p>

<p>prompt不算loss，可以随便写，但answer算loss，每一个标点符号都不能错。</p>

<p><strong>数据爬取</strong></p>

<p>找数据商家买，自己爬容易出事。</p>

<p>高质量的数据往往是论文、书籍等pdf格式，需要借助pdf2text工具。不能用python库，表格、公式识别效果不好。尽可能花钱买pdf2text服务，或者自己训ocr模型。gpt4的价格 可能大于 买pdf2text服务。</p>

<p><strong>知识密度概念</strong></p>

<p>数据的知识密度是有差异的。“唐诗三百首”的知识量要远远大于“中国新闻网的三百篇新闻”。<strong>高密度知识比较贵</strong>。</p>

<p>可以使用“手动合成高知识密度数据”技术，把几千字的新闻概括成几百字喂给模型，四舍五入也等于训练速度提高了十倍。</p>

<p><strong>数据清洗打分器清洗</strong></p>

<p>利用gpt4模型对数据质量进行打分是数据清洗的标配。但是，任何打分模型对code、markdown、latex都会打很低的分数，必须提取摘出来，不然这类数据全没了。</p>

<p>同等 size 下，BERT 结构的模型的表征能力是强于 transformer-decoder 模型，因此打分模型最好还是从 BERT 家族中选一个来训，效果好、速度还快。</p>

<p><strong>启发式规则清洗</strong></p>

<p>规则示例：数据长度是否少于某个值，数据中某个 token 的比例超过某个阈值，数据的 zh 占比、en 占比、数字占比，数据是否有“http”字段，数据是否包含了“新冠”、“疫情”等低质量关键词，数据是否包含某些反动词汇，数据是否包含某些黄色字眼。</p>

<p><strong>数据脱敏</strong></p>

<p>尽可能的把训练数据中涉及到的人名、电话号码、邮箱等剔除出去，一旦被模型说出来，就构成了隐私侵犯。还有”转载自，删掉，黄色信息，反动信息，references”。</p>

<p><strong>数据去重</strong></p>

<p>一般都是对T级别的数据去重。</p>

<p>去重的数据特征可能是：网页A引用网页B，网页B引用网页C，因此A、B、C只保留一个就行。同时还有，同一篇文章在知乎、CSDN、博客等发布。</p>

<p>能对sentence去重就不要对document去重，但工作量剧增。</p>

<p>去重一定有大数据处理集群，Hadoop、spark等。</p>

<p>去重算法：minhash，gpt就能写。</p>

<p>需要 10T 训练数据，去重相似度设为80%，5T的话 ，去重相似度设为90%</p>

<p>如果去重不彻底，一篇文章重复出现，可以间隔多个token降低影响。</p>

<p>不同类别数据阈值不同，“新闻”类可能 70% 的重复度就不要，“知识”类则可以 85% 的相似度才丢弃，在丢去重复文档的时候，优先保留数据打分器比较高的数据。</p>

<p><strong>数据配比</strong></p>

<p>假设数据集已经用分类器(gpt4、bert、人工)划分为不同类别，如新闻、百科、代码、markdown、等。</p>

<p>数据配比，基本上都是“知识 + 代码 + 逻辑”三个大类目。其中知识数据分文中文知识和英文知识，<strong>逻辑数据</strong>则可以认为是 <strong>math 数据和 cot 数据</strong>的混合体。</p>

<p>整体上，大部分中文模型的配比都在这个区间  -&gt;   [中：英：code] = [4:4:2]（逻辑数据的比例我没有写进去，加入多少取决于你能收集多少，其他三类数据应该是要多少有多少的存在）。</p>

<p>可以根据实际情况调整，但英文数据不能太低，且一般<strong>中文数据质量远低于英文数据</strong>。原因可能为：</p>

<p>1.中文确实比英文难学，语言空间的复杂度更高；</p>

<p>2.中文语料无论是干净程度还是数量级，都无法与英文语料相比较。</p>

<p><strong>数据多样性prompt 表达方式多样性</strong>，不要千篇一律的“把中文句子 A 翻译成英文”，也要适当有一些“我在英国旅游，我现在需要向路人问路，我想表达 A 的意思，该怎么说”，“我是一个英文老师，我需要向我的学生讲解句子 A 用英文怎么写，请你用最正宗的表达方式帮我完成。”这么做的目的是防止模型只认识 prompt 中的几个关键 token，进而导致训练过拟合或者泛化性变差；</p>

<p><strong>对于每种 task_type 的数据量</strong>，别搞平均主义：难 task_type 就数据多点，简单 task_type 就数据少点。</p>

<p><strong>prompt 长度均衡</strong>，既要有短数据，也要有长数据，避免模型的 attention 退化到无法聚焦长 prompt。长数据还不只是字面意思的长，要有那种关键信息藏在 【开头 / 中间 / 结尾】 的各种数据场景，避免模型在训练时偷懒，只对 prompt 的起始 token 或结束 token 有 attention；</p>

<p><strong>answer 长度均衡</strong>。不能让模型没出输几个 token 就停止，适当的有一些语料让它学会输出尽量长的 answer，否则模型会很难 follow “不少于2000字” 这种指令；</p>

<p><strong>多轮聊天的切换 主题 能力</strong>。有的数据当前 query 是和 session 有关系的，有的数据则是当前 query 和 session 毫无关系，要让模型自己学会判断 query 是否和 session 有关。类似的数据还要有 system 是否生效，有些数据 system 是个摆设，有些数据的 answer 则和 system 直接相关；</p>

<p><strong>answer 分布的多样性</strong>。<strong>这最重要</strong>，千万别总共一万条训练数据，一千条数据的 answer 都说同一句话，answer 可是算 loss 的，太单一的话会严重让模型过拟合；</p>

<p><strong>数据中要有一些鲁棒性数据。</strong> answer 很正常，但 prompt 表达很差劲的训练语料 。prompt 差指的是，它或者是有错别字，或者是话没说完整，亦或者是中文英文拼音夹杂着表达。不用担心会破坏模型效果，毕竟 prompt 根本不算 loss，这么做的目的是适应线上用户的糟糕表达，<strong>没有一个用户会希望听到“不是我们的模型不行，而是你 prompt 写的不行”</strong>这种观点（糟糕 prompt 的理解能力，感觉国内模型和 GPT4 的差距挺大的）。</p>

<p><strong>复杂数据——不少于多少字。</strong>先射箭，再画靶。你搞了一个很复杂的 prompt ，但模型的回答没有遵循复杂指令。直接去修改 prompt，原本的 prompt 要求模型输出不少于 200 字，实际上只输出了 189 个字，那就把 prompt 改成不少于 180 字。</p>]]></content><author><name>niuteng5618</name></author><category term="大模型技术" /><category term="训练与微调" /><category term="预训练" /><category term="预训练" /><category term="Tokenization" /><category term="数据清洗" /><category term="训练流程" /><summary type="html"><![CDATA[开源数据集：FineWeb、pile、Skypile、RedPajama，但开源的肯定多少有不干净的数据。 prompt不算loss，可以随便写，但answer算loss，每一个标点符号都不能错。 数据爬取 找数据商家买，自己爬容易出事。 高质量的数据往往是论文、书籍等pdf格式，需要借助pdf2text工具。不能用python库，表格、公式识别效果不好。尽可能花钱买pdf2text服务，或者自己训ocr模型。gpt4的价格 可能大于 买pdf2text服务。 知识密度概念 数据的知识密度是有差异的。“唐诗三百首”的知识量要远远大于“中国新闻网的三百篇新闻”。高密度知识比较贵。 可以使用“手动合成高知识密度数据”技术，把几千字的新闻概括成几百字喂给模型，四舍五入也等于训练速度提高了十倍。 数据清洗打分器清洗 利用gpt4模型对数据质量进行打分是数据清洗的标配。但是，任何打分模型对code、markdown、latex都会打很低的分数，必须提取摘出来，不然这类数据全没了。 同等 size 下，BERT 结构的模型的表征能力是强于 transformer-decoder 模型，因此打分模型最好还是从 BERT 家族中选一个来训，效果好、速度还快。 启发式规则清洗 规则示例：数据长度是否少于某个值，数据中某个 token 的比例超过某个阈值，数据的 zh 占比、en 占比、数字占比，数据是否有“http”字段，数据是否包含了“新冠”、“疫情”等低质量关键词，数据是否包含某些反动词汇，数据是否包含某些黄色字眼。 数据脱敏 尽可能的把训练数据中涉及到的人名、电话号码、邮箱等剔除出去，一旦被模型说出来，就构成了隐私侵犯。还有”转载自，删掉，黄色信息，反动信息，references”。 数据去重 一般都是对T级别的数据去重。 去重的数据特征可能是：网页A引用网页B，网页B引用网页C，因此A、B、C只保留一个就行。同时还有，同一篇文章在知乎、CSDN、博客等发布。 能对sentence去重就不要对document去重，但工作量剧增。 去重一定有大数据处理集群，Hadoop、spark等。 去重算法：minhash，gpt就能写。 需要 10T 训练数据，去重相似度设为80%，5T的话 ，去重相似度设为90% 如果去重不彻底，一篇文章重复出现，可以间隔多个token降低影响。 不同类别数据阈值不同，“新闻”类可能 70% 的重复度就不要，“知识”类则可以 85% 的相似度才丢弃，在丢去重复文档的时候，优先保留数据打分器比较高的数据。 数据配比 假设数据集已经用分类器(gpt4、bert、人工)划分为不同类别，如新闻、百科、代码、markdown、等。 数据配比，基本上都是“知识 + 代码 + 逻辑”三个大类目。其中知识数据分文中文知识和英文知识，逻辑数据则可以认为是 math 数据和 cot 数据的混合体。 整体上，大部分中文模型的配比都在这个区间 -&gt; [中：英：code] = [4:4:2]（逻辑数据的比例我没有写进去，加入多少取决于你能收集多少，其他三类数据应该是要多少有多少的存在）。 可以根据实际情况调整，但英文数据不能太低，且一般中文数据质量远低于英文数据。原因可能为： 1.中文确实比英文难学，语言空间的复杂度更高； 2.中文语料无论是干净程度还是数量级，都无法与英文语料相比较。 数据多样性prompt 表达方式多样性，不要千篇一律的“把中文句子 A 翻译成英文”，也要适当有一些“我在英国旅游，我现在需要向路人问路，我想表达 A 的意思，该怎么说”，“我是一个英文老师，我需要向我的学生讲解句子 A 用英文怎么写，请你用最正宗的表达方式帮我完成。”这么做的目的是防止模型只认识 prompt 中的几个关键 token，进而导致训练过拟合或者泛化性变差； 对于每种 task_type 的数据量，别搞平均主义：难 task_type 就数据多点，简单 task_type 就数据少点。 prompt 长度均衡，既要有短数据，也要有长数据，避免模型的 attention 退化到无法聚焦长 prompt。长数据还不只是字面意思的长，要有那种关键信息藏在 【开头 / 中间 / 结尾】 的各种数据场景，避免模型在训练时偷懒，只对 prompt 的起始 token 或结束 token 有 attention； answer 长度均衡。不能让模型没出输几个 token 就停止，适当的有一些语料让它学会输出尽量长的 answer，否则模型会很难 follow “不少于2000字” 这种指令； 多轮聊天的切换 主题 能力。有的数据当前 query 是和 session 有关系的，有的数据则是当前 query 和 session 毫无关系，要让模型自己学会判断 query 是否和 session 有关。类似的数据还要有 system 是否生效，有些数据 system 是个摆设，有些数据的 answer 则和 system 直接相关； answer 分布的多样性。这最重要，千万别总共一万条训练数据，一千条数据的 answer 都说同一句话，answer 可是算 loss 的，太单一的话会严重让模型过拟合； 数据中要有一些鲁棒性数据。 answer 很正常，但 prompt 表达很差劲的训练语料 。prompt 差指的是，它或者是有错别字，或者是话没说完整，亦或者是中文英文拼音夹杂着表达。不用担心会破坏模型效果，毕竟 prompt 根本不算 loss，这么做的目的是适应线上用户的糟糕表达，没有一个用户会希望听到“不是我们的模型不行，而是你 prompt 写的不行”这种观点（糟糕 prompt 的理解能力，感觉国内模型和 GPT4 的差距挺大的）。 复杂数据——不少于多少字。先射箭，再画靶。你搞了一个很复杂的 prompt ，但模型的回答没有遵循复杂指令。直接去修改 prompt，原本的 prompt 要求模型输出不少于 200 字，实际上只输出了 189 个字，那就把 prompt 改成不少于 180 字。]]></summary></entry><entry><title type="html">算法面试问题</title><link href="https://niuteng5618.github.io/007-yuque-007/" rel="alternate" type="text/html" title="算法面试问题" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>https://niuteng5618.github.io/007-yuque-007</id><content type="html" xml:base="https://niuteng5618.github.io/007-yuque-007/"><![CDATA[<p><strong>如何理解float16、int4，是如何计算的？</strong></p>

<p>参考下面的问题：</p>

<p>以Llama 7B模型为例，hidden_size为4096，也就说每个K,V有4096 个数据，假设是半精度浮点数据float16，一个Transformer Block中就有 4096* 2(每个元素占2字节) *2(K和V) = 16KB的单序列 K,V缓存空间，而Llama 2一共32个Transformer Block，所以单序列整个模型需要16 * 32 = 512KB的缓存空间。</p>

<p>每个Key和Value向量的大小：在Llama 7B模型中，每个Key（K）和Value（V）向量的大小是4096维，也就是说，每个K或V向量包含4096个元素。</p>

<p><strong>数据类型</strong>：假设使用的是<strong>半精度浮点数（float16）</strong>，每个元素占用2字节（16位 / 8 = 2字节）。</p>

<p>单个Transformer Block中的缓存空间需求：</p>

<p>每个K或V向量占用的空间为 4096×2=8192 字节。</p>

<p>每个Transformer Block中有两个这样的向量（K和V），所以总的空间为 8192×2=16384 字节(16KB)。</p>

<p>整个模型的缓存空间需求：</p>

<p>Llama 2模型包含32个Transformer Block。</p>

<p>整个模型的缓存空间需求为 16KB×32=512KB。</p>

<p>假如<strong>数据类型是int4</strong>，则每个<strong>K、V向量占0.5字节</strong>。</p>

<p><strong>数据类型有哪些？</strong></p>

<p>数据类型还有：</p>

<p>int8(1字节)，int4(0.5字节)，float16(半精度浮点型2字节)、float32(单精度浮点型，4字节)、float64(双精度浮点型，8字节)、bfloat16(2字节)</p>

<p>bfloat16(bf16)、float16(fp16)、float32(fp32)区别：</p>

<p><img src="/images/yuque/007-yuque-007/image-1-f6a28394.png" alt="" /></p>

<p><strong>RoPE相对原来的绝对位置编码(Sinusoidal位置编码)有什么改进？</strong></p>

<p>绝对位置编码，使用三角函数生成每个位置的唯一绝对位置信息(三角函数的主要用途是具有可去分析的周期性模式)，然后与embedding的结果进行简单相加，从而使之后的向量计算中保留绝对位置信息。</p>

<p>这种绝对位置编码的方式在多层传递后会导致位置信息的稀释，也就是在处理较长的序列时能力有限，可能会导致梯度消失等问题。</p>

<p>RoPE不再是简单的加法，而是做内积，通过将m-n的相对位置嵌入到计算中，利用旋转变换将位置信息融入token表示中,强化了位置编码信息的作用。为了避免衰减特性(Q、K越远，注意力分数越低)，θ保持了原来绝对位置编码的数值。</p>

<p><strong>LLaMA模型为什么RoPE只旋转Q、K，而V是原数值？</strong></p>

<p>在计算注意力权重时，旋转Q和K确保了位置编码对权重计算的影响。然而，V并不直接参与权重计算，它仅在加权求和中作为信息传递的一部分，用来生成最终的注意力输出。因此，将V保持原值可以避免不必要的干扰，确保信息的一致性和准确性。</p>

<p>head_dim 、hidden_dim 、n_local_heads 、seq_len、batch_size区别联系</p>

<p>hidden_dim 是整个模型中每个位置嵌入的维度。</p>

<p>head_dim是每个注意力头的维度，决定了每个头处理信息的能力。</p>

<p>n_local_heads是局部注意力机制中使用的头数，适用于局部注意力的变体</p>

<p>seq_len是输入或输出序列的长度，影响模型计算的范围和复杂度</p>

<p>batch_size是每次训练或推理过程中处理的样本数量</p>

<p>hidden_dim 是多头注意力中 head_dim 的总维度，head_dim 是 hidden_dim 的一部分。</p>

<p>hidden_dim = head_dim * num_heads(头总数)</p>

<p>6.<strong>为什么大模型都是decoder-only?</strong></p>

<p>encoder架构在本文理解方面更出色，更适合进行embedding和分类任务。decoder则由于其自回归特性，更能捕捉语言的顺序和上下文信息，更适合文本生成。</p>

<p>decoder-only可以在不同的prompt下进行微调，适应各种对话任务。</p>]]></content><author><name>niuteng5618</name></author><category term="算法与面试" /><category term="算法面试" /><category term="算法" /><category term="面试" /><category term="复杂度" /><category term="数据结构" /><summary type="html"><![CDATA[如何理解float16、int4，是如何计算的？ 参考下面的问题： 以Llama 7B模型为例，hidden_size为4096，也就说每个K,V有4096 个数据，假设是半精度浮点数据float16，一个Transformer Block中就有 4096* 2(每个元素占2字节) *2(K和V) = 16KB的单序列 K,V缓存空间，而Llama 2一共32个Transformer Block，所以单序列整个模型需要16 * 32 = 512KB的缓存空间。 每个Key和Value向量的大小：在Llama 7B模型中，每个Key（K）和Value（V）向量的大小是4096维，也就是说，每个K或V向量包含4096个元素。 数据类型：假设使用的是半精度浮点数（float16），每个元素占用2字节（16位 / 8 = 2字节）。 单个Transformer Block中的缓存空间需求： 每个K或V向量占用的空间为 4096×2=8192 字节。 每个Transformer Block中有两个这样的向量（K和V），所以总的空间为 8192×2=16384 字节(16KB)。 整个模型的缓存空间需求： Llama 2模型包含32个Transformer Block。 整个模型的缓存空间需求为 16KB×32=512KB。 假如数据类型是int4，则每个K、V向量占0.5字节。 数据类型有哪些？ 数据类型还有： int8(1字节)，int4(0.5字节)，float16(半精度浮点型2字节)、float32(单精度浮点型，4字节)、float64(双精度浮点型，8字节)、bfloat16(2字节) bfloat16(bf16)、float16(fp16)、float32(fp32)区别： RoPE相对原来的绝对位置编码(Sinusoidal位置编码)有什么改进？ 绝对位置编码，使用三角函数生成每个位置的唯一绝对位置信息(三角函数的主要用途是具有可去分析的周期性模式)，然后与embedding的结果进行简单相加，从而使之后的向量计算中保留绝对位置信息。 这种绝对位置编码的方式在多层传递后会导致位置信息的稀释，也就是在处理较长的序列时能力有限，可能会导致梯度消失等问题。 RoPE不再是简单的加法，而是做内积，通过将m-n的相对位置嵌入到计算中，利用旋转变换将位置信息融入token表示中,强化了位置编码信息的作用。为了避免衰减特性(Q、K越远，注意力分数越低)，θ保持了原来绝对位置编码的数值。 LLaMA模型为什么RoPE只旋转Q、K，而V是原数值？ 在计算注意力权重时，旋转Q和K确保了位置编码对权重计算的影响。然而，V并不直接参与权重计算，它仅在加权求和中作为信息传递的一部分，用来生成最终的注意力输出。因此，将V保持原值可以避免不必要的干扰，确保信息的一致性和准确性。 head_dim 、hidden_dim 、n_local_heads 、seq_len、batch_size区别联系 hidden_dim 是整个模型中每个位置嵌入的维度。 head_dim是每个注意力头的维度，决定了每个头处理信息的能力。 n_local_heads是局部注意力机制中使用的头数，适用于局部注意力的变体 seq_len是输入或输出序列的长度，影响模型计算的范围和复杂度 batch_size是每次训练或推理过程中处理的样本数量 hidden_dim 是多头注意力中 head_dim 的总维度，head_dim 是 hidden_dim 的一部分。 hidden_dim = head_dim * num_heads(头总数) 6.为什么大模型都是decoder-only? encoder架构在本文理解方面更出色，更适合进行embedding和分类任务。decoder则由于其自回归特性，更能捕捉语言的顺序和上下文信息，更适合文本生成。 decoder-only可以在不同的prompt下进行微调，适应各种对话任务。]]></summary></entry></feed>