<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<atom:link href="https://liuyaowen.cn/feed" rel="self" type="application/rss+xml"/>
<title>刘耀文</title>
<link>https://liuyaowen.cn</link>
<description>刘耀文个人网站，聚焦技术分享、项目展示与成长记录，涵盖前端开发、人工智能、个人作品集等内容，致力于打造专业、有温度的开发者主页。</description>
<language>zh-CN</language>
<copyright>© 刘耀文 </copyright>
<pubDate>Sat, 24 Oct 2026 12:53:29 GMT</pubDate>
<generator>Mix Space CMS (https://github.com/mx-space)</generator>
<docs>https://mx-space.js.org</docs>
<image>
    <url>https://avatars.githubusercontent.com/u/55525531?v=4</url>
    <title>刘耀文</title>
    <link>https://liuyaowen.cn</link>
</image>
<item>
    <title>Vite 教程：从项目配置、构建原理到插件与全栈框架集成</title>
    <link>https://liuyaowen.cn/posts/web-typescript/vite-tutorial</link>
    <pubDate>Sat, 17 Oct 2026 06:19:20 GMT</pubDate>
    <description>打开一个 Vite 项目的 Network 面板，会看到浏览器在请求 /src/main.ts。浏览</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/web-typescript/vite-tutorial'>https://liuyaowen.cn/posts/web-typescript/vite-tutorial</a></blockquote>
          <p>打开一个 Vite 项目的 Network 面板，会看到浏览器在请求 <code>/src/main.ts</code>。浏览器并不认识 TypeScript，这个页面却能运行。等到执行 build，原来的模块又不一定有了：几个文件合进一份 JS，一次动态 import 还可能带出几个 chunk。</p>
<p>问题就出在这里：我们写的源码、Vite 处理的模块、浏览器下载的文件，已经是不同的东西。只看配置，很容易把它们当成一回事。</p>
<p>这篇文章按一个项目实际需要的顺序讲 Vite：先建项目，处理配置、TypeScript、CSS 和资源，再看开发请求、热更新、构建与部署。有了这些基础，再拆开插件的 Hook，最后看 React、SSR、服务端函数和 Workers 运行时怎么接进来。遇到看不懂的地方，我们会回到一个具体模块，看它从哪里来、被改成什么、由谁执行。</p>
<p>本文配有 <a href="https://github.com/liyown/vite-study/tree/v1.0.0">11 节渐进实验</a>。要求读者会使用 JavaScript 的 <code>import/export</code>、Promise 和基本命令行；React 只在相关章节使用。所有实验都能在本机运行，不需要云账号。源码固定 Vite 8.3.4、React 19.3.1、<code>@vitejs/plugin-react</code> 6.1.2；验证环境为 Node 24.22.0、pnpm 10.24.0。可复验范围与记录见 <a href="https://github.com/liyown/vite-study/blob/v1.0.0/docs/verification.md">验证说明</a>。</p>
<p>文章的章节号与教材课程号不同：章节按解释顺序展开，课程号只标识对应实验。只需要日常开发与交付的读者，可以先读前两部分；插件与框架部分在此基础上继续深入。</p>
<ul>
<li>基础：<a href="#1__1-vite-%E8%B4%9F%E8%B4%A3%E5%93%AA%E4%BA%9B%E5%B7%A5%E4%BD%9C">Vite 的职责</a> · <a href="#2__2-%E4%BB%8E%E9%9B%B6%E5%88%9B%E5%BB%BA%E9%A1%B9%E7%9B%AE%E5%85%88%E8%AE%A4%E8%AF%86%E4%B8%89%E4%B8%AA%E5%91%BD%E4%BB%A4">创建项目</a> · <a href="#3__3-%E9%85%8D%E7%BD%AE%E6%A8%A1%E5%9D%97%E8%A7%A3%E6%9E%90%E5%92%8C-typescript-%E5%88%86%E5%88%AB%E8%B4%9F%E8%B4%A3%E4%BB%80%E4%B9%88">配置与 TypeScript</a> · <a href="#4__4-css%E5%9B%BE%E7%89%87%E5%92%8C%E6%96%87%E6%9C%AC%E6%80%8E%E6%A0%B7%E8%BF%9B%E5%85%A5%E6%A8%A1%E5%9D%97%E5%9B%BE">CSS 与资源</a></li>
<li>开发与交付：<a href="#6__5-%E5%BC%80%E5%8F%91%E6%9C%8D%E5%8A%A1%E5%99%A8%E4%B8%BA%E4%BB%80%E4%B9%88%E8%83%BD%E8%AE%A9%E6%B5%8F%E8%A7%88%E5%99%A8%E6%89%A7%E8%A1%8C-typescript">模块服务</a> · <a href="#7__6-hmr-%E5%A6%82%E4%BD%95%E6%8A%8A%E6%96%87%E4%BB%B6%E6%94%B9%E5%8A%A8%E5%8F%98%E6%88%90%E9%A1%B5%E9%9D%A2%E6%9B%B4%E6%96%B0">HMR 与 React</a> · <a href="#9__7-mode%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E4%B8%8E%E8%BF%90%E8%A1%8C%E7%9B%AE%E6%A0%87%E4%B8%8D%E8%A6%81%E6%B7%B7%E5%9C%A8%E4%B8%80%E8%B5%B7">mode 与 env</a> · <a href="#10__8-%E7%94%9F%E4%BA%A7%E6%9E%84%E5%BB%BA%E6%80%8E%E6%A0%B7%E5%90%88%E5%B9%B6%E6%A8%A1%E5%9D%97%E6%8B%86%E5%88%86-chunk">构建与部署</a></li>
<li>扩展机制：<a href="#13__9-%E6%8F%92%E4%BB%B6%E7%9A%84%E6%A0%B8%E5%BF%83-hook-%E6%80%8E%E6%A0%B7%E4%B8%B2%E8%B5%B7%E6%9D%A5">核心 Hook 流程</a></li>
<li>框架与实践：<a href="#20__10-%E5%A4%9A-environment-%E6%80%8E%E6%A0%B7%E6%94%AF%E6%92%91-ssr">多环境 SSR</a> · <a href="#21__11-%E4%B8%80%E4%BB%BD%E6%9C%8D%E5%8A%A1%E7%AB%AF%E5%87%BD%E6%95%B0%E6%BA%90%E7%A0%81%E4%B8%BA%E4%BB%80%E4%B9%88%E8%83%BD%E7%94%9F%E6%88%90%E4%B8%A4%E7%AB%AF%E7%A8%8B%E5%BA%8F">服务端函数</a> · <a href="#23__12-%E7%94%9F%E6%80%81%E6%A1%86%E6%9E%B6%E9%80%9A%E5%B8%B8%E6%80%8E%E6%A0%B7%E6%8E%A5%E5%85%A5-vite">生态集成</a> · <a href="#24__13-%E6%80%A7%E8%83%BD%E4%B8%8E%E6%95%85%E9%9A%9C%E5%BA%94%E6%8C%89%E9%98%B6%E6%AE%B5%E6%8E%92%E6%9F%A5">排错</a> · <a href="#25__14-%E7%94%A8%E9%85%8D%E5%A5%97%E5%AE%9E%E9%AA%8C%E5%A4%8D%E9%AA%8C%E6%9C%AC%E6%96%87">复验</a></li>
</ul>
<h2>第一部分：项目与模块</h2>
<h3>1. Vite 负责哪些工作</h3>
<p>先把开发和部署分开。运行 dev 时，Vite 接收浏览器请求，转换源码，维护模块关系；运行 build 时，它从入口收集依赖，组织出可以部署的文件。这是两种交付方式，最终执行程序的仍然是浏览器或服务器运行时。</p>
<p>所以页面里一个 React 按钮能工作，至少涉及两件事：源码被转换成可执行的 JS，React 运行时把组件变成 DOM。Vite 把前一件事接起来，并不替 React 完成后一件事。后面判断“某个能力是否需要插件”，就要先分清它属于哪一步。</p>
<pre><code class="language-text">业务源码与资源
    ↓ Vite 配置、插件与内置处理
    ├─ 开发：模块服务 → 浏览器或服务器运行器加载 → 执行
    └─ 构建：依赖分析、优化、分包 → dist → 部署服务加载 → 执行</code></pre><p>读源码前先看版本。本文的 Vite 8 使用 Rust 编写的 Rolldown 打包，并整合 Oxc；React 插件 v6 的默认 Refresh 转换也使用 Oxc。网上“开发用 esbuild、生产用 Rollup”的解释有它的版本背景；拿它分析这里的调用链，就会找错工具。<a href="https://vite.dev/blog/announcing-vite8">Vite 8 发布说明</a>列出了这次调整。</p>
<p>Rust 打包器可以调用 JavaScript 插件：Vite 配置与插件函数运行在 Node 的 JavaScript 环境，原生绑定把需要的调用与结果交给 Rust。两种语言通过接口协作，不要求插件也使用 Rust。想实际观察双向调用，可以运行配套的 <a href="https://github.com/liyown/vite-study/blob/v1.0.0/native/01-js-rust/README.md">JS/Rust 小实验</a>；它演示同步绑定，不模拟 Rolldown 的完整调度系统。</p>
<h3>2. 从零创建项目，先认识三个命令</h3>
<p>新建项目可以使用官方脚手架：</p>
<pre><code class="language-bash">pnpm create vite my-app --template vanilla-ts
cd my-app
pnpm install
pnpm dev</code></pre><p>脚手架会随版本更新。如果要复现本文的结果，使用配套仓库与锁文件：</p>
<pre><code class="language-bash">git clone --branch v1.0.0 https://github.com/liyown/vite-study.git
cd vite-study
pnpm install --frozen-lockfile
pnpm dev 01</code></pre><p>Vite 8 的 Node 最低要求是 20.19+、22.12+；模板还可能要求更高版本。本仓库使用 <code>.node-version</code> 指定验证版本。新建项目的操作见<a href="https://vite.dev/guide/">官方入门文档</a>。</p>
<table>
<thead>
<tr>
<th>命令</th>
<th>输入</th>
<th>输出或行为</th>
</tr>
</thead>
<tbody><tr>
<td><code>vite</code> / <code>vite dev</code></td>
<td>源码、配置</td>
<td>启动开发服务器，按需要提供模块</td>
</tr>
<tr>
<td><code>vite build</code></td>
<td>构建入口及其依赖</td>
<td>生成生产产物，通常写入 dist</td>
</tr>
<tr>
<td><code>vite preview</code></td>
<td>已有 dist</td>
<td>本地检查构建结果，不重新编译</td>
</tr>
</tbody></table>
<p>项目建好以后，先打开 <code>index.html</code>，别急着看配置。网页应用默认从 root 下这份 HTML 进入，它通过模块脚本引出后面的代码：</p>
<pre><code class="language-html"><div></div>
&lt;script type="module" src="/src/main.ts"&gt;&lt;/script&gt;</code></pre><p>Vite 会处理这份 HTML，再沿着模块脚本找依赖。因此它要放在项目 root，不能照搬某些工具的目录习惯，把入口塞进 public。root 也不必是 Git 仓库根：在多项目仓库里，从错误目录启动，很可能连配置和 env 文件都找错。</p>
<p><a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/01-vanilla/README.md">入口与模块图实验（课程 01）</a>提供了最初的观察点：刷新页面记录 Network，点击“加载编辑器”后再看新增请求。随后 build，打开 manifest，对照这个模块在生产里的去向。源码关系与交付文件的区别，会在这两份结果中出现。</p>
<h3>3. 配置、模块解析和 TypeScript 分别负责什么</h3>
<p>一个常见的配置文件如下：</p>
<pre><code class="language-ts">import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';

export default defineConfig(({ command, mode }) =&gt; ({
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  server: {
    port: 5173,
    strictPort: true,
  },
  build: { manifest: true },
}));</code></pre><p>这份配置在 Node 侧加载，里面的 <code>node:url</code> 不会因此进入浏览器。<code>defineConfig</code> 帮助检查配置类型；<code>command</code> 告诉我们现在是 serve 还是 build，mode 则是另一项选择，稍后单独讨论。alias 用绝对路径，是为了让解析结果不随启动目录漂移。配置加载器的差异可以继续查<a href="https://vite.dev/config/">官方配置文档</a>。</p>
<p>模块解析要回答的是“这个 import 指向哪里”。相对路径、包名和别名分别走相应的解析规则；包的 exports、条件与目标环境也会影响最终选择。路径大小写错误可能在本机不明显，到 Linux 构建机才暴露。</p>
<p>编辑器接受了路径，构建器却说找不到模块，通常要分别检查 TypeScript 和 Vite 的解析配置。<a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/02-project-basics/README.md">配置与资源实验（课程 02）</a>显式设置 paths 与 alias。关闭 Vite alias 会导致模块解析失败；要观察类型检查失败，则把子 tsconfig 的 paths 临时覆盖成 <code>{}</code>，再运行 <code>pnpm exec tsc --noEmit -p examples/02-project-basics/tsconfig.json</code>。仅删除子配置项会继承根映射，不能隔离这个条件。实验后恢复，再检查通过。Vite 8 另有可选的 <code>resolve.tsconfigPaths</code>；本实验采用显式配置，是为了让两侧的职责可见。</p>
<p>再做一个小改动：给只接受字符串的函数传入数字。build 仍可能成功，<code>tsc --noEmit</code> 才会报告类型错误。Vite 这里做的是转译，项目要把类型检查单独接入开发或 CI。网络输入还要再过一关：给 JSON 写一个 TypeScript 类型，并不能阻止服务器真的返回错误数据。</p>
<h3>4. CSS、图片和文本怎样进入模块图</h3>
<p>看到下面几种 import，先判断变量里装的是什么，别把它们都理解成“把文件引进来”：</p>
<pre><code class="language-ts">import './style.css';                    // 把样式接入页面
import styles from './card.module.css';  // 得到局部类名映射
import logoUrl from './mark.svg';        // 得到资源 URL
import rawText from './note.txt?raw';    // 得到原始文本</code></pre><pre><code class="language-text">源码中的资源引用
    ↓ 解析路径和导入形式
    ├─ CSS → 开发样式更新 / 生产 CSS 资源
    ├─ CSS Module → 类名映射 + 对应样式
    ├─ 普通资源 → URL，可能是独立文件或内联 data URL
    └─ ?raw → 导出的字符串</code></pre><p><code>logoUrl</code> 是最值得看的一项。开发时它指向一个资源地址，构建后可能是带 hash 的文件，也可能是内联 data URL。图片被 import，Vite 就能跟踪并改写这条引用。</p>
<p>public 走另一条路。文件原样提供和复制，适合必须保留文件名的资源。当 base 为 <code>/</code> 时，<code>public/handbook.txt</code> 的访问路径是 <code>/handbook.txt</code>，不带 public。部署到子路径时，用 <code>import.meta.env.BASE_URL</code> 构造这种公开资源地址；课程中的写法是 <code>${import.meta.env.BASE_URL}handbook.txt</code>。选择这条路，就接受它不参与同一套 hash 管理。内联阈值等细节见<a href="https://vite.dev/guide/assets">资源文档</a>。</p>
<p>CSS Module 提供局部类名，CSS 的层叠和选择器规则仍然存在。Sass、Less 等预处理器需要安装对应实现；PostCSS 与其他 CSS 工具还各有配置。先使用普通 CSS 看懂处理过程，再按项目需求接入，通常更容易排错。</p>
<p>批量加载也可以进入依赖图：</p>
<pre><code class="language-ts">const articles = import.meta.glob('./articles/*.txt', {
  query: '?raw',
  import: 'default',
});</code></pre><p>这里得到的不是已经读好的所有文本，而是一张“路径 → 加载函数”表。点击某篇文章时调用对应函数，才触发加载；加上 <code>eager: true</code>，才改成立即导入。模式必须写成静态字面量，因为 Vite 需要提前知道依赖集合，不能等部署后再遍历你的源码目录。</p>
<p>Worker 的常用写法是：</p>
<pre><code class="language-ts">const worker = new Worker(new URL('./sum.worker.ts', import.meta.url), {
  type: 'module',
});</code></pre><p>Vite 可以分析这种形式，处理 Worker 的源码与生产地址；运行时仍需通过消息通信，Worker 不能访问页面 DOM。glob、CSS 与 Worker 的支持范围见<a href="https://vite.dev/guide/features">内置能力文档</a>。资源实验把这些能力放在一个页面，可逐项检查。</p>
<h2>第二部分：开发与交付</h2>
<h3>5. 开发服务器为什么能让浏览器执行 TypeScript</h3>
<p>回到开头的 <code>/src/main.ts</code>。在 Network 里点开它，看 Response，不要只看文件名。类型标注已经消失，import 路径也可能变了。浏览器执行的是这段响应里的 JavaScript；<code>.ts</code> 只是这个开发请求的 URL 后缀。</p>
<pre><code class="language-text">GET /
    ↓ 处理 index.html，加入开发客户端
浏览器请求 /src/main.ts
    ↓ 路径解析 → 加载源码 → 插件和内置转换 → import 分析与 URL 改写
响应 JavaScript
    ↓ 浏览器解析 import
继续请求后续模块</code></pre><p>沿着这个过程观察，会看到业务模块是按需要提供的，并没有先等待整个应用完成生产打包。已转换的模块可以命中缓存，预加载也会提前请求一些依赖；这张图描述的是处理关系，不是每次请求都从头执行的脚本。</p>
<p>再看一个包名导入：<code>import React from &#39;react&#39;</code>。浏览器不会去 node_modules 里按 Node 的规则寻找包，Vite 要先替它解析，并交付可以请求的 URL。依赖预构建还处理 CommonJS/UMD 兼容，以及依赖内部过多的小模块请求。<a href="https://vite.dev/guide/dep-pre-bundling">依赖预构建文档</a>说明了什么时候重做、什么时候复用缓存。</p>
<p>如果链接进来的本地包只有在第二次启动后才正常，或者某个 CommonJS 包只在开发时失败，就该检查解析结果与优化日志，再考虑 <code>optimizeDeps.include/exclude</code>。我不建议先堆一份很长的配置列表。<code>--force</code> 可以用来排除旧缓存的影响；如果每次都要加它，问题还没有定位完。</p>
<p>开发优化也不能代替生产验证。生产还会进行 tree shaking、分包、压缩和资源输出；开发能运行，只验证了其中一条路径。</p>
<h3>6. HMR 如何把文件改动变成页面更新</h3>
<p>开发时还有一个经常出现在 Network 里的地址：<code>/@vite/client</code>。它是 Vite 注入的开发客户端，用来接收更新、配合模块替换、处理样式与错误提示。保存文件后，服务器和它之间传递更新消息；浏览器随后加载需要的新模块。</p>
<pre><code class="language-text">保存 message.js
    ↓ 服务端标记相关模块转换缓存失效
沿导入关系寻找 HMR 接受边界
    ↓ 发送更新消息，包含边界 path、更新模块 acceptedPath 等信息
浏览器热更新客户端加载带新时间戳的模块 URL
    ↓ 得到新模块导出
调用已登记的 accept 回调
    ↓ 回调或框架运行时更新页面</code></pre><p>只把新代码下载下来，页面还不会凭空知道该怎么变。服务端模块图先找到接受边界，浏览器端还要知道收到新导出后执行什么。下面把这件事直接写出来：</p>
<pre><code class="language-js">import { getMessage } from './message.js';
const app = document.querySelector('#app');
app.textContent = getMessage();

if (import.meta.hot) {
  import.meta.hot.accept('./message.js', nextModule =&gt; {
    if (nextModule) app.textContent = nextModule.getMessage();
  });
}</code></pre><p>关键是 <code>nextModule.getMessage()</code>。第一次赋值使用旧模块，更新后的赋值由这个回调完成。如果删掉回调，Vite 不知道原来的 DOM 是哪个函数调用产生的，也就不能替我们重做它。它会按模块关系继续寻找边界，必要时重新加载页面。接受边界和失效传播的约定见 <a href="https://vite.dev/guide/api-hmr">HMR API</a>。</p>
<p>CSS 是一个容易观察的例子。普通导入的 CSS 在开发时会变成调用 <code>updateStyle</code> 的 JS，模块自接受更新；再次执行时更新对应 style 元素。源码是 <code>.css</code>，更新消息出现 <code>js-update</code> 并不矛盾。生命周期结束时，还可以通过 prune 清理对应样式。</p>
<p>有资源需要清理时，用 dispose 处理监听器、定时器等；需要跨更新保留少量数据时可以使用 hot.data。HMR 不会替应用自动管理所有副作用。</p>
<h4>React 插件多做了什么</h4>
<p>运行裸 React 与 Fast Refresh 对照实验：在两个终端分别执行 <code>pnpm dev 03</code> 和 <code>pnpm dev 04</code>，打开 :5173 与 :5174，计数器都点到 3。只改各自 Counter 的标题：裸配置一侧归零，加入 React 插件的一侧仍为 3。两边的业务源码相同，TSX 原本也都能转换。</p>
<p><code>pnpm compare:react</code> 检查实际转换结果，输出中的两个标志为：</p>
<pre><code class="language-text">03-react-bare     jsxWasTransformed: true    hasRefreshInstrumentation: false
04-react-refresh  jsxWasTransformed: true    hasRefreshInstrumentation: true</code></pre><p>JSX 已转换，只能说明语法被处理了；组件注册与接受更新的代码才把这次改动接入 Refresh。修改 Hook 顺序或使导出边界失效，仍可能重置状态，不能将标题编辑的结果扩大到任意修改。</p>
<pre><code class="language-text">首次加载 Counter_v1
    ↓ Refresh 注册稳定标识，建立组件 family
修改后加载 Counter_v2
    ↓ 同一标识关联新旧实现，检查模块导出与 Hook 签名
performReactRefresh 更新 family.current
    ↓ 通知 React 渲染器
React 在渲染阶段解析到新实现
    ↓ 兼容时保留 Fiber 上的状态，不兼容时重新挂载</code></pre><p>这一步很容易看错。父模块可能还拿着 Counter_v1，JSX 也确实把这个函数交给 React。新版本被选中的位置，在 React 的开发渲染流程：渲染器通过组件函数找到 family，再读取当前实现。family 关联版本，Fiber 保存实例状态。把它误解成 ESM 自动替换全部旧 import，后面写普通 JavaScript 的 HMR 就会做出错误假设。</p>
<p>React 插件的边界处理可对照<a href="https://github.com/vitejs/vite-plugin-react/blob/ac47648e17674299accb51816e9e33b8a7b07b05/packages/plugin-react/src/index.ts">固定版本源码</a>。React 项目中的 <a href="https://github.com/facebook/react/blob/main/packages/react-refresh/src/ReactFreshRuntime.js">Refresh Runtime</a>与<a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHotReloading.js">渲染器热更新处理</a>展示了两侧如何配合；main 链接用于阅读，复现实验仍以仓库锁定版本为准。</p>
<h3>7. mode、环境变量与运行目标不要混在一起</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>决定什么</th>
<th>例子</th>
</tr>
</thead>
<tbody><tr>
<td>mode</td>
<td>选择一组开发/构建配置和 env 文件</td>
<td>development、staging</td>
</tr>
<tr>
<td><code>NODE_ENV</code></td>
<td>影响开发/生产运行语义及相关常量</td>
<td>development、production</td>
</tr>
<tr>
<td>Vite Environment</td>
<td>当前模块的解析、转换和构建上下文</td>
<td>client、ssr</td>
</tr>
<tr>
<td>runtime</td>
<td>真正执行程序的宿主</td>
<td>浏览器、Node、workerd</td>
</tr>
</tbody></table>
<p>在没有预设 <code>NODE_ENV</code> 的情况下，<code>vite build --mode staging</code> 的 <code>MODE</code> 是 staging，<code>PROD</code> 仍为 true。<code>--mode development</code> 也不会单凭名字把生产构建变成开发服务。<a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/05-modes/README.md">mode 与 env 实验（课程 05）</a>给出了命令与实际值的对照。<a href="https://vite.dev/guide/env-and-mode">模式文档</a>解释了 mode 与 <code>NODE_ENV</code> 的区别。</p>
<p>同名变量的覆盖顺序由低到高是 <code>.env</code> → <code>.env.local</code> → <code>.env.[mode]</code> → <code>.env.[mode].local</code> → 启动进程时已有的环境变量。只在某一个文件定义的值仍然可用。修改 env 后重启开发服务器。</p>
<p>浏览器里的 <code>import.meta.env.VITE_FEATURE</code> 通常是字符串，<code>Boolean(&#39;false&#39;)</code> 仍是 true，应按业务约定显式解析。<code>VITE_</code> 表示默认允许进入客户端，而不是加密或隐藏。配置阶段需要读取 env 时使用 <code>loadEnv</code>，不要把全部 process.env 填进客户端 define。</p>
<p>部署后改 env，前端为什么没变？可以直接打开产物 JS 找这个值：它在构建时就被写进去了，服务器此时改的是另一个进程的环境。如果确实需要上线后变更，应让服务器返回一份公开运行时配置，或在 HTML 中注入它，并约定浏览器能读到哪些字段。</p>
<h3>8. 生产构建怎样合并模块、拆分 chunk</h3>
<p>先区分两个词：模块是依赖图里的单位，chunk 是交付出去的代码文件。构建要把模块组织进 chunk，二者当然不必一一对应。<a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/06-build-deploy/README.md">构建与部署实验（课程 06）</a>用两个 HTML 入口和一个动态模块展示这件事：</p>
<pre><code class="language-text">index.html → main.ts → shared.ts
admin/index.html → admin.ts → shared.ts
main.ts → import(lazy.ts)
    ↓ 构建
两份 HTML + 入口 chunk + 可能的共享 chunk + 动态 chunk + CSS/资源</code></pre><p>这里的 shared 同时被首页和管理页使用。如果复制两份会浪费代码，把它提取出来又会增加依赖请求；打包器需要按依赖关系和输出策略安排。动态 import 提供按需加载边界，但遇到共享依赖时，一次调用可能需要几个文件。盯着“一个源码对应一个 JS”看产物，很快就会困惑。</p>
<p>那文件名变了，import 怎么办？打开入口产物就能看到：<code>import(&#39;./lazy.ts&#39;)</code> 已经被改成 <code>import(&#39;./assets/lazy-abc123.js&#39;)</code> 一类的路径。声明之间的引用和重名变量也需要一起处理。所以构建器必须理解模块关系。随手写进 JSON 的路径字符串，没有这种 import 关系，不能指望它自动跟着改名。</p>
<p>入口实验还有一个观察点：先看没有使用的导出消失，再给模块顶层加一条 console.log。后者即使没有被谁调用，也已经改变了模块求值时的行为，不能按“没引用”随便删掉。Tree shaking 依赖静态分析和副作用信息；把 sideEffects 错写成 false，也可能把真正需要的初始化删掉。</p>
<p>手动分包会改变加载与共享关系，甚至影响副作用触发时机，不能把“文件更多”直接等同于更快。<a href="https://rolldown.rs/in-depth/manual-code-splitting">Rolldown 分包文档</a>说明了相关控制。先观察真实依赖、入口下载体积和交互时的加载，再决定是否手动调整。</p>
<p>构建与部署实验同时提供多页面、动态模块和库构建。常用配置的作用如下：</p>
<table>
<thead>
<tr>
<th>配置</th>
<th>作用与限制</th>
</tr>
</thead>
<tbody><tr>
<td><code>base</code></td>
<td>资源公共基础路径，需要与实际服务器挂载路径一致</td>
</tr>
<tr>
<td><code>build.target</code></td>
<td>控制目标语法兼容，不自动补齐所有缺失的浏览器 API</td>
</tr>
<tr>
<td><code>build.manifest</code></td>
<td>输出入口、资源和依赖映射，供后端模板或检查工具使用</td>
</tr>
<tr>
<td><code>build.sourcemap</code></td>
<td>输出调试映射，也可能公开源码，需要明确分发策略</td>
</tr>
<tr>
<td><code>build.rolldownOptions</code></td>
<td>配置底层入口和输出等构建选项</td>
</tr>
<tr>
<td><code>build.lib</code></td>
<td>以代码导出为入口构建库，另需考虑声明文件、exports 与依赖约定</td>
</tr>
</tbody></table>
<p>网站构建默认关心 HTML 和浏览器资源，库构建关心消费者可以导入哪些导出；发布 TypeScript 库还需要类型声明生成。相关配置见<a href="https://vite.dev/guide/build">生产构建文档</a>。</p>
<h4>产物怎样部署才一致</h4>
<p>静态网站可以把 dist 交给静态服务器或 CDN。<code>vite preview</code> 用于本地检查。<a href="https://vite.dev/guide/static-deploy">官方部署指南</a>列出了不同平台的接入方式。</p>
<p>base 是 <code>/study/</code> 时，服务器也需要把这个路径映射到产物。SPA 的客户端路由需要 HTML fallback，多页面则需要对应的 HTML；缺失的 JS、CSS 和 API 不应被误回退成 HTML，否则容易出现 MIME 类型错误。</p>
<p>还有一种问题只会在发布后出现：用户开着旧页面，过一会儿才点击动态功能，旧 JS 要找的 chunk 已经被新部署删掉了。hash 保证了文件内容身份，却不保证旧文件还在。HTML 和资源要按版本一致交付，旧资源需要留存策略；HTML 通常重新验证缓存，hash 资源可以长期缓存。SSR 则还要部署服务器入口及其外部依赖，不能只上传 client 目录。</p>
<h2>第三部分：扩展 Vite</h2>
<h3>9. 插件的核心 Hook 怎样串起来</h3>
<p>现在再打开 plugins，情况就具体了。一个插件可能补配置，也可能把特殊文件变成 JS，或者为开发服务器装一个接口。工厂返回的是这些工作的 Hook 对象；它还可以返回一组插件。<code>plugins: [a(), b()]</code> 注册了处理者，运行时由 Vite 在不同阶段调用它们。</p>
<p>读插件源码时，我会先找它接管了哪一种输入。改配置、处理模块、处理请求和生成产物，要看的是不同位置。把所有 Hook 先抄成一张执行顺序表，反而容易误读。</p>
<h4>配置阶段：把用户配置变成可用上下文</h4>
<pre><code class="language-text">用户配置、CLI 参数、插件列表
    ↓ config(config, configEnv)
插件补充或返回部分配置，Vite 继续合并
    ↓ configEnvironment(name, options)
按环境补充选项
    ↓ configResolved(resolvedConfig)
读取最终 root、mode 等配置，准备后续处理</code></pre><p>配置还在合并时，可以通过 config 补充 alias 或 define。到了 configResolved，最终 root 等结果已经可读，适合保存下来供后面使用。此时修改配置，不会让 Vite 自动重做已经完成的解析。这里也还不是“某个模块正在 client 环境转换”的时刻，不能给所有配置 Hook 都套上 <code>this.environment</code>。</p>
<h4>模块阶段：名字怎样变成可执行源码</h4>
<p>用 <code>import { message } from &#39;virtual:lesson&#39;</code> 走一遍：</p>
<pre><code class="language-text">输入 source = 'virtual:lesson'，importer = 当前导入者
    ↓ resolveId(source, importer)
输出内部模块 ID '\0virtual:lesson'
    ↓ load(id)
输出 'export const message = "hello"'
    ↓ transform(code, id)
输出改写后的 code + map
    ↓ 后续转换、依赖分析
开发响应 / 构建依赖图</code></pre><p>最小插件如下：</p>
<pre><code class="language-js">function lessonPlugin() {
  return {
    name: 'lesson',
    resolveId(source) {
      if (source === 'virtual:lesson') return '\0virtual:lesson';
    },
    load(id) {
      if (id === '\0virtual:lesson') {
        return 'export const message = "hello"';
      }
    },
  };
}</code></pre><p>这里没有创建文件。resolveId 把公开名字认领成内部 ID，load 给这个 ID 提供源码；接下来的步骤就能把它当成模块处理。生成的源码里若写着 import，依赖分析仍会继续追下去。后面的 RPC 注册表正是这样进入服务器模块图的。</p>
<table>
<thead>
<tr>
<th>Hook</th>
<th>常见输入</th>
<th>有效结果交给谁</th>
</tr>
</thead>
<tbody><tr>
<td><code>resolveId</code></td>
<td>import 字符串、导入者、解析选项</td>
<td>解析后的模块 ID，交给加载步骤</td>
</tr>
<tr>
<td><code>load</code></td>
<td>模块 ID</td>
<td>模块源码，交给转换步骤</td>
</tr>
<tr>
<td><code>transform</code></td>
<td>当前源码、模块 ID</td>
<td>新源码与 map，交给后续转换和分析</td>
</tr>
</tbody></table>
<p>三个 Hook 的合作方式也不同。一个插件已经给出有效的 load 结果，通常就不需要再让另一个 loader 重新提供源码；transform 却可能把前一个插件的结果继续加工。它们不是同一种 reduce。写插件前要看清返回类型：null、字符串、对象和数组，在不同 Hook 里承担的职责不同。具体约定见 <a href="https://vite.dev/guide/api-plugin">Plugin API</a>。</p>
<p><a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/07-plugin-lab/README.md">插件实验（课程 07）</a>中的文本宏只处理约定后缀，替换一个受控占位符，并返回 source map。这个范围用文本处理就能说明问题。如果要识别“名为 createServerFn 的调用确实来自某个包”，正则就不够了：局部变量可以同名，导入也可以有别名。后面的框架编译实验才引入 AST 和绑定分析。复杂度应由要处理的语义决定。</p>
<h4>开发阶段：服务建立与变更传播</h4>
<pre><code class="language-text">开发服务器创建
    ↓ configureServer(server)
注册 HTTP 中间件、接入框架服务
    ↓ HTML 请求 → transformIndexHtml → 模块请求 → resolve/load/transform
文件变化
    ↓ 当前环境 hotUpdate(context)
检查受影响模块、调整更新范围或发送自定义事件
    ↓ Vite 传播更新 → 客户端接受边界 → 执行更新逻辑
服务器关闭/重启
    ↓ closeServer
释放对应服务资源</code></pre><p>configureServer 能拿到开发服务器，所以你可以在这里装中间件；返回后置安装函数，还能把中间件放在内部处理之后。但 build 不会把这个闭包抄进 dist。插件实验的开发接口在预览时消失，就是为了让这个边界可见。生产 API 则由后面的 Node API 实验单独提供。</p>
<p>transformIndexHtml 则既能参与开发，也能参与构建，判断时不能仅看它是不是“Vite 自有 Hook”。热更新也一样：新 hotUpdate 和兼容的 handleHotUpdate 有各自的环境上下文，应该看所用版本。</p>
<p>插件实验的文本 loader 返回可导入字符串，<code>this.addWatchFile</code> 声明需要观察的文件，hotUpdate 发送通知；页面的 accept 回调更新对应 DOM。这几部分共同完成热更新，单有一个 Hook 不足以保证业务状态保留。</p>
<h4>构建阶段：模块怎样进入最终输出</h4>
<pre><code class="language-text">构建初始化、options / outputOptions 等配置处理
    ↓ buildStart
准备当前构建状态
    ↓ resolveId → load → transform，递归处理依赖
moduleParsed / buildEnd 等分析阶段通知
    ↓ renderStart
进入 chunk 输出阶段
    ↓ renderChunk(code, chunk)
调整 chunk 代码，按需返回 map
    ↓ generateBundle(options, bundle)
查看 chunk/asset，emitFile 添加资源
    ↓ 写盘 → writeBundle
构建器关闭 → closeBundle</code></pre><p>开发容器也会复用部分构建 Hook，例如初始化时的 buildStart；普通开发请求不进入完整 chunk 输出阶段。</p>
<p>这里有两个实测结果，能说明为什么不该死记一条顺序。锁定版本的 trace 中，outputOptions 可以早于 buildStart；用户 generateBundle 被调用时，Vite 也可能还没有生成 HTML。若插件想在这个位置修改 HTML，单凭“已经 generateBundle 了”就会判断错。要么选择合适的 HTML Hook，要么核对相关插件的输出顺序。</p>
<p>下面是 <code>pnpm docs:hooks:trace</code> 的写盘构建日志摘录，中间的模块转换调用已省略：</p>
<pre><code class="language-text">options
outputOptions
buildStart
...
generateBundle  { isWrite: true, hasHtml: false }
transformIndexHtml
writeBundle</code></pre><p><code>hasHtml: false</code> 只描述这个探针被调用时的输出集合，不表示 Vite 没有生成 HTML。HTML 在后续处理阶段加入，所以 Hook 所属阶段和插件排序都需要一起考虑。</p>
<p>transform 看一个模块，renderChunk 看组织好的 chunk，generateBundle 看这一份输出的文件集合。到了最后一处，就能从 modules、imports 和 dynamicImports 回答“源码放进哪一包、包之间怎样依赖”。用 emitFile 添加 JSON 报告，再等构建器写盘即可。</p>
<p>框架编译实验中的 function-manifest.json、compiled 文本和 build-report.json 都是为了阅读。实际 RPC 的查找表在 load 里生成。把报告当成执行机制，会多追一条根本不存在的运行时链路。</p>
<p>关闭逻辑也值得单独验证。构建可能在初始化一半时失败，输出失败又走另一条错误路径；本仓库的受控错误追踪里还观察过重复 closeBundle。释放资源应允许“已经释放过”，统计状态也要按构建轮次重置。否则 watch 一会儿以后，报告里很可能混进前几轮的数据。</p>
<h4>排序与环境状态有什么限制</h4>
<p><code>enforce: &#39;pre&#39;/&#39;post&#39;</code> 调整插件组的位置，一些 Hook 还支持自己的 order。配置和通知、并行与串行、first 与连续处理分别有各自约定。<code>apply</code>、<code>applyToEnvironment</code> 等还会限制插件在哪些场景生效。</p>
<p>多环境插件应在支持环境上下文的 Hook 中读取 <code>this.environment</code>。跨环境共享插件实例时，用以环境为键的 Map 保存记录，buildStart 只清理本轮对应环境。配置 Hook 和模块 Hook 的上下文不能互换。</p>
<p>全部 Hook、控制项与上下文成员放在<a href="https://github.com/liyown/vite-study/blob/v1.0.0/docs/plugin-hooks-reference.md">完整参考</a>，<a href="https://github.com/liyown/vite-study/blob/v1.0.0/docs/plugin-lifecycle.md">箭头生命周期</a>负责串联。<code>pnpm docs:hooks</code> 对照实际安装类型检查覆盖；<code>pnpm docs:hooks:trace</code> 运行构建成功、错误、开发和预览等六条路径，生成真实调度记录。完整清单用于查漏，不能替代对具体一次调用的观察。</p>
<h2>第四部分：框架与实践</h2>
<h3>10. 多 Environment 怎样支撑 SSR</h3>
<p>到这里，我们处理的主要还是浏览器程序。加上 SSR 后，问题变成了：同一个 App，一份要在服务器生成 HTML，另一份要在浏览器接上交互。源码可以共享，入口和执行目标却不同。</p>
<pre><code class="language-text">                     App.tsx
                    ↗       ↖
浏览器入口 entry-client    服务器入口 entry-server
    ↓ hydrateRoot             ↓ renderToString
client 模块图              ssr 模块图
    ↓                         ↓
浏览器程序                  Node 服务器程序</code></pre><p>如果只给同一张图加一个 ssr 布尔值，很快会遇到更多区别：包该选哪个条件导出？某个插件应该生成浏览器代码，还是服务器代码？输出又放在哪里？Environment 把这些处理上下文分开，一台 Vite 开发服务器可以服务多张图。它不是进程数量；runtime 才是代码实际执行的宿主。<a href="https://vite.dev/guide/api-environment">Environment API</a>正式表达了这些关系，部分接口仍在演进。</p>
<p><a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/08-environments-ssr/README.md">多环境 SSR 实验（课程 08）</a>声明 client、ssr，并启用应用构建。下面只摘出环境与入口配置，省略 imports、React 插件和 HTTP 宿主；可运行配置见课程源码：</p>
<pre><code class="language-ts">export default defineConfig({
  builder: {},
  environments: {
    client: { build: { outDir: 'dist/client' } },
    ssr: {
      build: {
        ssr: true,
        outDir: 'dist/server',
        rolldownOptions: { input: 'src/entry-server.tsx' },
      },
    },
  },
});</code></pre><p>配置声明处理目标，应用构建再编排这些目标。仅声明环境不能推导出任意构建命令都会自动产出所有环境；SSR 实验提供移除应用 builder 的对照。<a href="https://vite.dev/guide/api-environment-frameworks">框架接口</a>说明了应用构建与开发环境的编排方式。</p>
<p>一次 SSR 请求还需要 HTTP 宿主：</p>
<pre><code class="language-text">GET /
    ↓ Node HTTP 路由
开发：ssr.runner.import(服务端入口)
生产：Node import(已构建入口)
    ↓ 调用导出的 render
生成 HTML 与可公开的初始数据
    ↓ 填充模板，返回响应
浏览器加载 client 程序 → hydrateRoot</code></pre><p>注意图里的“调用 render”。Vite 可以把服务端模块交给运行器执行，但需要 HTTP 宿主接住请求，取出这个导出并调用。SSR 实验把这段编排写在 server.mjs 中，可以直接追踪到导出被调用的位置。<a href="https://vite.dev/guide/ssr">SSR 文档</a>也把它作为低层接入来说明。</p>
<p>浏览器随后要用同一份初始数据水合。如果服务器先算一次随机值，浏览器再算一次，两份输出就不再一致。初始数据还意味着一次公开交付：写进 HTML 的内容浏览器都能读到；序列化时也要处理标签结束等字符，不能把 JSON 直接拼进去就结束。</p>
<p><a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/09-fullstack-api/README.md">Node API 实验（课程 09）</a>单独展示 CSR + Node API：开发代理转发请求，生产由 Node 提供 API 和静态资源。它没有为后端配置第二张 Vite 图。全栈 API、SSR、多环境分别解决不同问题，可以组合，也可以分别采用。</p>
<h3>11. 一份服务端函数源码为什么能生成两端程序</h3>
<p><a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/10-tiny-rpc/README.md">最小 RPC 实验（课程 10）</a>只保留一个函数和一个短插件。业务模块只导出 <code>greet(name)</code>，内部使用 Node API；页面仍像普通函数一样导入它。</p>
<p>插件在转换时按环境处理：</p>
<pre><code class="language-text">同一个 greet.server.js
    ├─ client → 整个模块替换成 fetch('/rpc/greet') 代理
    └─ ssr → 保留真正的 Node 实现</code></pre><p>服务器入口导入插件生成的虚拟注册表，其内容相当于：</p>
<pre><code class="language-js">export const registry = {
  greet: () =&gt; import('./greet.server.js').then(module =&gt; module.default),
};</code></pre><pre><code class="language-text">浏览器 await greet('小明')
    ↓ POST /rpc/greet，发送 JSON 参数
服务器取出 ID greet
    ↓ await registry[id]()，加载模块并拿到函数
await fn('小明')
    ↓ JSON 响应 → 浏览器代理解包
await 得到结果</code></pre><p>函数就是在 <code>registry[id]()</code> 这一步找到的。没有远程传输函数，也没有到磁盘里搜索名为 greet 的声明；编译时已经写好“这个 ID 加载这个模块”。打包之后，ID 仍可以是 greet，import 路径则变成 <code>./assets/greet.server-某个hash.js</code>。两种标识的对应关系保存在代码里，打包器修改模块引用时一起维护它。</p>
<p>最小 RPC 把整个文件当成一个服务端函数，这是它能保持短小的原因。接下来再放宽一个条件：同一文件还要导出页面会用的常量。整文件替换就不合适了。继续支持导入别名、局部同名变量和共享 helper，又需要知道绑定关系。框架编译实验的 AST 编译器是在解决这些具体问题，可以按这个顺序逐步增加条件。</p>
<h4>TanStack Start 对应到哪里</h4>
<p><a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/11-mini-start/README.md">框架编译实验（课程 11）</a>参考 TanStack/router 的固定提交 <code>7a0672503857283614799a0b480f4a82110e5ab6</code>。在这个快照的<a href="https://github.com/TanStack/router/blob/7a0672503857283614799a0b480f4a82110e5ab6/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts">服务端函数编译器</a>中，可以看到 client caller、SSR caller 与 provider 等角色；本地<a href="https://github.com/liyown/vite-study/blob/v1.0.0/examples/11-mini-start/SOURCES.md">源码对照</a>逐项列出采用和省略的机制。</p>
<pre><code class="language-text">createServerFn().handler(真实实现)
    ↓ 根据模块角色编译
    ├─ client caller → HTTP 代理
    ├─ SSR caller → 服务器本地查表代理
    └─ provider → 保留真实实现，导出供注册表加载的入口</code></pre><p>这里最绕的是 provider。服务器自己也需要调用接口的代码：一份是“我要调用 getGreeting”的调用者，另一份才是保留真实 handler 的实现者。插件用 <code>?server-fn-provider</code> 给后者一个不同模块 ID，transform 看见 query，就生成实现端代码。两者仍在 ssr 图内；增加一个身份，并没有增加第三个环境或要求启动第三个服务器。</p>
<p>不要把所有类似名字的 API 都归入 RPC。<code>createIsomorphicFn</code> 为两端选择各自的本地实现；<code>createServerFn</code> 让调用跨越到服务器；<code>createServerOnlyFn</code> 限制执行目标，不意味着自动发布接口。TanStack 的<a href="https://tanstack.com/start/latest/docs/framework/react/guide/server-functions">服务端函数指南</a>描述了正式框架的用法，教学 API 只覆盖其中一部分。</p>
<p>一个可远程调用的函数最终是外部输入入口。正式应用还需要权限、输入校验、错误处理、请求上下文和传输协议等。编译时移除客户端 handler，不能代替服务端授权。客户端包也不是唯一公开渠道：把密钥写进 SSR 初始数据，或把原实现留在公开 source map，仍然会泄漏。</p>
<p>这次实现里真正出过问题的是 source map。检查客户端 JS 时，handler 和 Node 加密依赖都已移除；继续检查 map，却发现 sourcesContent 带回了原始 handler。代码执行路径已经隔离，源码分发路径却漏了。</p>
<p>具体触发条件是：转换已经删掉 handler，却返回 <code>{ mappings: &#39;&#39; }</code>，并开启客户端构建 source map。在本例 Vite 8.3.4 的后续处理中，最终 sourcesContent 带回了包含 <code>createHmac</code> 的原文。可以在临时目录比较错误版与当前版，脚本会清理临时输出，不修改课程源码：</p>
<pre><code class="language-bash">pnpm --filter @vite-study/11-mini-start compare:maps</code></pre><pre><code class="language-json">{"mode":"empty","sourcesContentContainsCreateHmac":true}
{"mode":"safe","sourcesContentContainsCreateHmac":false}</code></pre><p>当前版本把映射指向安全的转换后源码，并同时检查 JS 和 map。代价是失去原始业务源码的精细定位。这个结果限定于上述映射与构建链，不能推断所有工具中的空 map 都会泄漏。正式框架需要在隔离和定位精度之间设计更完整的映射策略。</p>
<h3>12. 生态框架通常怎样接入 Vite</h3>
<p>把“框架插件”按职责拆开，比只看 plugins 数组更容易定位问题：</p>
<table>
<thead>
<tr>
<th>接入层</th>
<th>常见工作</th>
<th>应该检查的结果</th>
</tr>
</thead>
<tbody><tr>
<td>语法与组件开发</td>
<td>JSX、单文件组件、开发注册、热更新边界</td>
<td>转换后的模块、编辑行为</td>
</tr>
<tr>
<td>应用框架</td>
<td>路由、双端入口、数据加载、SSR、服务端函数编译</td>
<td>两端模块图、调用协议、产物</td>
</tr>
<tr>
<td>运行时与部署适配</td>
<td>开发执行宿主、绑定、请求接口、构建与部署资产</td>
<td>实际运行时能力、请求入口、部署配置</td>
</tr>
</tbody></table>
<p>React 插件主要参与第一层；TanStack Start 组合应用约定与运行时；Cloudflare 插件则把相应环境接到 Workers 的开发和部署体系。一个框架可能横跨多层，表格用于区分责任，不是包之间绝对互斥的分类。</p>
<p>Workers 可以解释第三层为什么必要。如果本地一直在 Node 里执行，部署后却交给另一个运行时，开发时验证的 API、模块行为和 bindings 就未必是生产里的那一套。Cloudflare 在<a href="https://blog.cloudflare.com/introducing-the-cloudflare-vite-plugin/">介绍 Vite 插件的文章</a>里，把 workerd 接入开发环境作为重要变化。它解决的是代码在哪里执行，构建输出目录只是其中一部分。</p>
<p>框架仍负责组织应用，运行时提供者负责让相应环境中的代码能在正确宿主执行。Vite 的<a href="https://vite.dev/guide/api-environment-runtimes">运行时接口</a>和 <a href="https://developers.cloudflare.com/workers/vite-plugin/">Cloudflare 插件文档</a>把这个接缝展开说明。</p>
<p>Vite、框架和运行时适配器通过环境、入口及请求约定衔接。某个插件工厂可以返回多个插件，各自负责不同 Hook；数组顺序只是排序的一部分。真实项目应从框架官方集成模板开始，再观察最终配置，而不是把几份独立教程里的插件数组拼在一起。TanStack Start + Cloudflare 的具体配置应对照<a href="https://developers.cloudflare.com/workers/framework-guides/web-apps/tanstack-start/">官方集成指南</a>。配套仓库验证的是 Node 教学实现，没有声称完成这套云端部署。</p>
<p>阅读一个不熟悉的 Vite 框架，可以依次定位：配置如何加载、有哪些环境、各环境入口、特殊 import 的解析与转换、实际执行宿主，以及最终接收请求的处理器。找到这几个位置后，大量框架代码都能归到具体职责里。</p>
<h3>13. 性能与故障应按阶段排查</h3>
<p>性能问题先说清楚哪一步慢。是启动命令之后迟迟不能访问，第一次打开慢，保存后更新慢，还是生产首屏慢？同一句“Vite 很慢”背后，可能是四个不同位置的工作。打包速度和用户下载体积也要分别测量。</p>
<table>
<thead>
<tr>
<th>现象</th>
<th>先看什么</th>
<th>常见方向</th>
</tr>
</thead>
<tbody><tr>
<td>启动或首次页面慢</td>
<td>依赖优化日志、Network、插件转换耗时</td>
<td>新依赖扫描、过宽转换范围、昂贵插件、请求瀑布</td>
</tr>
<tr>
<td>编辑后更新慢或整页刷新</td>
<td>HMR 消息、模块导入关系、框架边界</td>
<td>广泛导入的模块、失效传播、重型转换</td>
</tr>
<tr>
<td>仅生产失败</td>
<td>完整 build 日志、产物、预览请求</td>
<td>大小写、静态分析限制、资源路径、外部依赖</td>
</tr>
<tr>
<td>JS 请求返回 HTML</td>
<td>Response 与 Content-Type、服务器 fallback</td>
<td>base/挂载路径错误、旧资源失效、回退规则过宽</td>
</tr>
<tr>
<td>SSR 出现 window 错误</td>
<td>堆栈所属入口、共享模块顶层代码</td>
<td>浏览器专用逻辑进入服务器目标</td>
</tr>
<tr>
<td>CI 类型失败但 build 成功</td>
<td>独立类型检查、各工具解析配置</td>
<td>转译与类型检查职责不同、alias 配置未对齐</td>
</tr>
</tbody></table>
<p>例如一个插件在每次 transform 中都遍历整个 src。单看某次扫描很快，模块多了，同一棵目录树却会被反复读很多次。这时应该先调整扫描时机和缓存失效，而不是去改 vendor 分包。先从日志或 profiler 找到耗时，再用相同操作比较变更前后；如果没有差别，就没有依据把这项改动叫优化。<a href="https://vite.dev/guide/performance">Vite 性能指南</a>给出了测量入口。</p>
<p>测试也按层次选择：纯函数用单元测试；插件转换检查输入输出；开发服务器和 SSR/RPC 用集成测试；HMR 的状态保留与水合行为用真实浏览器操作。Vitest 等工具可以接入 Vite 生态，但本文使用 Node 测试运行器验证构建与服务，避免引入一个测试框架后再解释其配置。</p>
<h3>14. 用配套实验复验本文</h3>
<p>主线先掌握日常使用，再进入扩展机制：</p>
<pre><code class="language-text">01 入口与模块图 → 02 配置、CSS 与资源
    ↓
03 裸 React → 04 Fast Refresh → 05 mode 与 env
    ↓
06 构建、库与部署 → 07 插件与 Hook
    ↓
08 多环境 SSR → 09 Node API → 10 最小 RPC → 11 框架式编译</code></pre><p>每节 README 提供运行地址、源码阅读顺序、修改实验、预期现象和自测。实验后恢复修改，再运行：</p>
<pre><code class="language-bash">pnpm check</code></pre><p>它执行类型检查、全部课程构建、行为验证、文档链接与课程顺序检查，以及 Hook 覆盖核对。浏览器操作和云端部署不由这条命令自动涵盖，范围见<a href="https://github.com/liyown/vite-study/blob/v1.0.0/docs/verification.md">验证记录</a>。</p>
<p>完成后可以用一个实际任务检验理解：为自己的项目画出“HTML/服务器入口 → 模块图 → 转换 → 产物 → 请求处理”图，并标出一个动态模块、一个框架 HMR 边界和一个服务器专属依赖。图里每条箭头都应能在 Network、源码或构建报告中找到对应证据。</p>
<p>作者：<a href="https://github.com/liyown">刘耀文</a>。本文源于这套本地学习实验，代码与文字使用 AI 辅助实现和整理；验证命令、版本与未覆盖范围保留在配套教材。勘误请提交到<a href="https://github.com/liyown/vite-study/issues">仓库 Issues</a>。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/web-typescript/vite-tutorial#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">193248195617230848</guid>
  <category>post</category>
<category>Web 与 TypeScript</category>
 </item>
  <item>
    <title>从零实现一个多轮意图识别助手：语料、状态、路由与评测</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-assistant-demo</link>
    <pubDate>Wed, 23 Sep 2026 03:19:41 GMT</pubDate>
    <description>前面的文章分别讨论了标签、语料、模型、状态、任务依赖和服务。现在把这些组件放到一起，实现一个可以连续</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-assistant-demo'>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-assistant-demo</a></blockquote>
          <p>前面的文章分别讨论了标签、语料、模型、状态、任务依赖和服务。现在把这些组件放到一起，实现一个可以连续对话的酒店助手：它能回答政策问题、查询模拟订单、补齐预订参数、处理修改与条件取消，并在确认后写入模拟结果。</p>
<p>本文介绍各模块的分工，以及安装、运行、切换识别器和测试的方法。<a href="https://liuyaowen.cn/api/v3/objects/file/7y89ughjykqpraph4k.zip">下载完整工程 v3.0</a>。</p>
<h2>一、运行对话示例</h2>
<p>解压后进入 <code>intent-recognition-lab</code> 目录。默认演示只使用 Python 标准库：</p>
<pre><code class="language-bash">python3 batch3/assistant.py</code></pre><p>它会运行预订、政策、条件取消、选择订单、修改和范围外请求等场景，输出每轮输入、路由、状态与结果。预订场景的主要变化如下：</p>
<table>
<thead>
<tr>
<th>用户输入</th>
<th>当前状态</th>
<th>返回结果</th>
</tr>
</thead>
<tbody><tr>
<td>我要订房</td>
<td>预订目标已建立，缺城市和日期</td>
<td>clarify：补充参数</td>
</tr>
<tr>
<td>北京 2026-10-01 2026-10-03</td>
<td>参数补齐，生成待确认方案</td>
<td>confirm：展示方案</td>
</tr>
<tr>
<td>确认</td>
<td>模拟订单写入，会话任务结束</td>
<td>mock_write：返回订单号</td>
</tr>
</tbody></table>
<p>这里“补齐参数”和“完成预订”是两步。用户提供日期时，程序只形成方案；明确确认之后，才执行模拟操作。</p>
<p>订单数据全部位于本地 SQLite。DEMO-A 的演示取消费为 0，DEMO-B 为 50，DEMO-X 属于另一个演示用户。它们用于观察条件与权限分支，没有连接真实酒店或支付系统。</p>
<h2>二、模块分工与请求流程</h2>
<p>以“取消订单 DEMO-A”为例，HTTP 或 CLI 入口先提供会话、请求 ID、状态版本和文本。服务读取当前状态，检查是否为已完成请求的重试，再将新请求交给对话处理。</p>
<p>识别器给出取消目标，槽位提取器找到订单号。状态模块把目标与对象绑定，路由层查询订单归属、状态、费用和版本，形成待确认方案。此时返回 <code>confirm</code>，数据库中的订单仍保持 booked。</p>
<p>下一轮“确认”由待确认状态解释，而不是重新当作一个没有上下文的分类问题。执行前再次核实订单版本与条件；成功后，在同一个本地事务中更新模拟订单、会话状态和去重结果。</p>
<p>可以把组件职责对应到文件：</p>
<table>
<thead>
<tr>
<th>文件</th>
<th>职责</th>
</tr>
</thead>
<tbody><tr>
<td>core.py</td>
<td>第一批输出契约与单任务状态更新</td>
</tr>
<tr>
<td>llm.py</td>
<td>可选的结构化 LLM 识别接口</td>
</tr>
<tr>
<td>batch2/baselines.py</td>
<td>规则、TF-IDF 和句向量候选分数</td>
</tr>
<tr>
<td>batch2/policy.py</td>
<td>分数与分差的接收、拒识和澄清决策</td>
</tr>
<tr>
<td>batch2/task_graph.py</td>
<td>多任务结构与依赖校验</td>
</tr>
<tr>
<td>batch3/assistant.py</td>
<td>上下文衔接、业务路由、模拟工具和持久化</td>
</tr>
<tr>
<td>batch3/planner.py</td>
<td>根据模拟查询事实推进任务图</td>
</tr>
<tr>
<td>batch3/service.py</td>
<td>本地 HTTP 请求契约</td>
</tr>
<tr>
<td>batch3/evaluate.py</td>
<td>读取预测结果并计算分类与拒识指标</td>
</tr>
</tbody></table>
<p>第一批的识别输出契约与第二批任务图仍然分别保留。单任务状态与多任务计划承担不同职责，通过明确入口连接，比把所有字段塞进一个不断膨胀的 JSON 更容易理解。</p>
<h2>三、短回复与上下文处理</h2>
<p>用户说“取消订单”但没给订单号时，演示系统列出两个选项：“1 DEMO-A；2 DEMO-B。”下一轮的“1”先在当前有效选项里解释，得到 DEMO-A，再继续取消流程。</p>
<p>它不会进入一条全局的“数字 1 代表取消订单”规则。选项记录属于当前会话和当前任务；另一个会话中的“1”没有同样的含义。</p>
<p>日期与房型补充也类似。预订任务进行中，“北京 2026-10-01 2026-10-03”可以补入当前参数。用户明确提出新的预订目标时，则应切换任务，不能将新需求误当成旧任务的参数补充。</p>
<p>因此，程序会先处理确认回复、选项选择和参数补充，再调用通用意图识别器。利用已有的上下文信息，可以减少不必要的推断。</p>
<h2>四、跨轮保留条件约束</h2>
<p>运行“如果免费就取消订单 DEMO-B”，系统查询到演示费用为 50，返回 <code>condition_false</code>，没有进入确认，也不会取消订单。</p>
<p>如果用户最初只说“如果免费就取消订单”，条件需要跟随当前任务保存。下一轮选择 DEMO-B 后，仍然必须检查“免费”约束，不能因为这一轮只有数字，就把条件丢失。</p>
<p>即使条件在提出方案时成立，确认前也可能变化。附件保存方案使用的订单版本，并在提交前重新读取费用和状态。版本不一致或条件变化时返回冲突，交由上层重新展示方案。</p>
<p>更复杂的多任务请求，可以运行：</p>
<pre><code class="language-bash">python3 batch3/planner.py</code></pre><p>该命令接收系列第六篇定义的任务图。真实运行结果中，订单查询和查房节点完成，取消节点停在 <code>requires_business_validation</code>。它展示了依赖如何推进，也保留了写操作的确认入口。自由表达转成任意任务图，仍是识别与规划模块需要完成的工作；本命令从明确的结构化计划开始。</p>
<h2>五、切换意图识别器</h2>
<p>默认规则适合直接阅读和运行，覆盖教程使用的表达。需要句向量候选时，安装模型实验依赖并启动：</p>
<pre><code class="language-bash">python -m pip install -r batch2/requirements.txt
python batch3/service.py --recognizer vector --db /tmp/intent-vector.sqlite3</code></pre><p>句向量分支沿用前面固定的模型与验证参数，可能将支持不足的输入送去澄清。更换候选方法，不会自动放宽订单核验、参数检查或确认流程。</p>
<p>选择 <code>--recognizer llm</code> 时，适配器读取 <code>OPENAI_API_KEY</code> 与 <code>OPENAI_MODEL</code>，调用已有结构化接口。模板在 <code>batch3/.env.example</code>，服务不会自动加载它，需要由运行环境注入变量。LLM 返回已知的单一目标后，仍交给同一套状态与业务约束。</p>
<p>当前演示将候选识别与简化槽位提取分开，日期使用明确的 ISO 格式。扩展自然日期、复杂指代或完整 LLM 槽位结果时，应在接口适配层加入规范化和字段校验，而不是绕过状态模块直接执行工具。规则、句向量和可选 LLM 也不应被假定具有相同的识别覆盖。</p>
<p>本次验证包含本地规则链路与句向量接入；LLM 入口保留可运行配置，未消耗在线 API。银行八类学生模型用于展示训练流程，其标签与酒店不同，没有被直接接入酒店路由。</p>
<h2>六、通过 HTTP 接口完成对话</h2>
<pre><code class="language-bash">python3 batch3/service.py --db /tmp/intent-demo.sqlite3 --port 8765</code></pre><p>第一轮提出取消请求：</p>
<pre><code class="language-bash">curl http://127.0.0.1:8765/turn \
  -H 'Content-Type: application/json' \
  -d '{"session_id":"cancel-demo","request_id":"r1","expected_version":0,"text":"取消订单 DEMO-A"}'</code></pre><p>收到版本 1 的待确认方案后，再提交：</p>
<pre><code class="language-bash">curl http://127.0.0.1:8765/turn \
  -H 'Content-Type: application/json' \
  -d '{"session_id":"cancel-demo","request_id":"r2","expected_version":1,"text":"确认"}'</code></pre><p>若响应丢失，原样重发第二条请求即可读取保存的结果。不要只为了重试就生成新请求 ID。同一个 ID 换成其他文本，会返回幂等冲突；新请求携带旧状态版本，会返回状态冲突。</p>
<p>再次运行示例时，可以换一个会话 ID；模拟订单是数据库中的持久对象，已经取消的订单不会因新会话自动恢复。需要完整重置时，改用一个新的数据库文件路径。</p>
<h2>七、运行测试与模型实验</h2>
<p>从工程根目录执行：</p>
<pre><code class="language-bash">python3 -m unittest discover -s tests -v
python3 batch2/tests.py
python3 batch3/tests.py
python3 batch3/http_smoke.py</code></pre><p>前三项检查契约、状态、任务图和完整工作流；最后一项会启动真实的回环 HTTP 服务，发送请求并核对响应，然后结束服务。</p>
<p>重点用例包括：补槽后预订、咨询不触发操作、条件跨轮保留、确认前订单变化、费用变化、重复请求不重复写入、不同会话隔离、服务超时回滚、进程重启后继续任务。</p>
<p>模型与指标实验则另外运行：</p>
<pre><code class="language-bash">python batch3/evaluate.py
python batch3/train_small.py
python batch3/infer_small.py "My card was charged twice"</code></pre><p>功能测试检查程序的行为，分类实验检查模型对数据的判断。两类结果都保存在工程中，阅读时可以沿某条失败记录追到对应组件。</p>
<h2>八、接入自己的业务</h2>
<p>先替换能力目录和标签定义，再适配自己的槽位与业务对象。将模拟订单工具替换成真实服务时，要明确认证身份、工具幂等键、超时后的结果查询、费用变化与操作确认。</p>
<p>知识分支当前使用少量带来源的政策条目，目的是看清路由与证据返回。接入文档检索后，可以继续增加切块、混合检索、重排和答案组织，但对当前用户订单的读取仍应留在受权限约束的业务工具中。</p>
<p>并发服务应将耗时识别移到短事务之外，再使用版本检查提交；远程操作通过自身幂等与对账机制恢复。模型、Prompt、标签、阈值和路由版本应一起保存，才能复现一次线上判断。</p>
<p>接入新业务时，需要保留用户目标、上下文和条件约束，并根据这些信息选择下一步。操作是否完成，应以业务系统保存的结果为准。</p>
<h2>九、系列阅读导航</h2>
<table>
<thead>
<tr>
<th>想解决的问题</th>
<th>对应文章</th>
</tr>
</thead>
<tbody><tr>
<td>系统里意图识别负责什么</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/202503211">总览</a></td>
</tr>
<tr>
<td>标签怎样划分</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design">标签设计</a></td>
</tr>
<tr>
<td>复杂对话怎样标注</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/complex-intent-recognition-corpus">语料与下载</a></td>
</tr>
<tr>
<td>规则、向量、小模型怎样选</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-rules-vectors-baselines">基础方法</a></td>
</tr>
<tr>
<td>LLM 怎样返回稳定接口</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/llm-intent-recognition-structured-output">结构化识别</a></td>
</tr>
<tr>
<td>短回复与改口怎样理解</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/multi-turn-intent-recognition-context">多轮状态</a></td>
</tr>
<tr>
<td>多目标和条件怎样表示</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/multi-intent-recognition-task-dependencies">任务依赖</a></td>
</tr>
<tr>
<td>何时拒识与追问</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification">未知意图与澄清</a></td>
</tr>
<tr>
<td>改进是否有效</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation">评测方法</a></td>
</tr>
<tr>
<td>怎样训练更轻量的识别器</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning">分类头与蒸馏</a></td>
</tr>
<tr>
<td>识别后走哪条处理路径</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/rag-agent-intent-routing">RAG 与 Agent 路由</a></td>
</tr>
<tr>
<td>服务怎样可靠运行</td>
<td><a href="https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service">服务与错误闭环</a></td>
</tr>
</tbody></table>
<p>读完并运行项目后，再回看每篇文章里的独立概念，会更容易理解它们为什么需要分开，以及它们如何共同影响一段对话的最终结果。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-assistant-demo#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184505679124369408</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>意图识别服务上线：延迟、成本、监控与错误闭环</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service</link>
    <pubDate>Wed, 23 Sep 2026 03:19:41 GMT</pubDate>
    <description>一个识别器在脚本中能返回正确标签，接入服务后仍可能出现另一类问题：请求超时被重复提交，两次回复覆盖同</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service'>https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service</a></blockquote>
          <p>一个识别器在脚本中能返回正确标签，接入服务后仍可能出现另一类问题：请求超时被重复提交，两次回复覆盖同一会话状态，用户已经改了日期，迟到的结果却仍然按旧参数执行。</p>
<p>处理这些问题，需要关注从接收请求到保存结果的整个过程。本文介绍请求标识、状态版本、幂等、超时、缓存和错误样本处理，并提供本地 HTTP 服务供运行和测试。<a href="https://liuyaowen.cn/api/v3/objects/file/7y89ughjykqpraph4k.zip">下载服务与故障用例</a>。</p>
<p></p>
<h2>一、设计请求接口</h2>
<p>一个有状态的识别请求，除了文本，还需要知道它属于哪个会话、是不是重试，以及基于哪个状态版本产生。</p>
<pre><code class="language-json">{
  "session_id": "hotel-demo",
  "request_id": "turn-002",
  "expected_version": 1,
  "text": "北京 2026-10-01 2026-10-03",
  "budget_ms": 2000
}</code></pre><p><code>session_id</code> 定位会话，<code>request_id</code> 标识一次逻辑请求，<code>expected_version</code> 表示调用方看到的状态版本。三个字段不能互相替代：同一会话有很多请求，同一请求可能发送多次，多个不同请求也可能基于同一个旧版本。</p>
<p>真实服务还需要从认证上下文获取用户身份，将会话和业务对象绑定到该身份。请求正文里的用户自述不能替代认证。本地附件固定使用演示身份，接口只监听回环地址，方便读者验证流程。</p>
<h2>二、请求幂等与状态版本</h2>
<p>第一次请求已经成功，但响应在网络中丢失。客户端重试时，应沿用原来的 <code>request_id</code>。服务端发现这次逻辑请求已经完成，直接返回保存的结果，而不再次更新状态或取消订单。</p>
<p>如果同一个请求 ID 带来了不同文本，就不是正常重试。可以保存请求关键字段的指纹，发现不同内容时返回冲突，避免旧结果被错误用于新需求。</p>
<p>状态版本处理另一种情况：两条不同请求都基于版本 3，一条修改日期，另一条确认预订。若修改先提交到版本 4，确认就不能继续覆盖版本 4。条件更新应明确要求当前版本仍为 3，否则返回冲突，让客户端重新读取状态。</p>
<table>
<thead>
<tr>
<th>情况</th>
<th>合理行为</th>
</tr>
</thead>
<tbody><tr>
<td>同请求 ID、同内容，已有结果</td>
<td>返回原结果</td>
</tr>
<tr>
<td>同请求 ID、不同内容</td>
<td>幂等冲突</td>
</tr>
<tr>
<td>新请求 ID、状态版本过旧</td>
<td>状态冲突</td>
</tr>
<tr>
<td>新请求 ID、版本匹配</td>
<td>继续处理并提交新版本</td>
</tr>
</tbody></table>
<p>检查顺序也重要。正常重试携带的旧版本，可能已经被第一次成功请求推进。因此应先检查已完成的请求记录，再判断新请求是否基于过期状态。</p>
<h2>三、用事务保证数据一致性</h2>
<p>如果服务先更新状态、后保存去重记录，中间崩溃，重试时就可能再次执行。附件把会话状态、请求结果、审计事件和模拟订单操作放进同一个 SQLite 事务：要么全部保存，要么全部回滚。</p>
<p>SQLite 提供事务和提交、回滚机制，适合演示这些状态之间的一致性关系。<a href="https://docs.python.org/3/library/sqlite3.html">Python sqlite3 文档</a></p>
<p>真实订单系统通常在另一个服务中，不能直接加入本地数据库事务。此时至少需要将逻辑操作 ID 传给工具，让工具自身支持幂等；如果调用超时，先按操作 ID 查询结果，再决定是否重试。必要时用 outbox、任务状态机和结果对账处理跨系统的一致性。</p>
<p>特别要区分“调用返回失败”和“业务操作没有发生”。连接在提交后断开时，两者可能不同。立即换一个新请求 ID 再调用，会绕过去重机制，产生重复操作。</p>
<h2>四、设置超时与重试策略</h2>
<p>一个请求的总预算为两秒，不能让检索、模型和工具每一步都各自等待两秒。应保存绝对截止时间，每一步计算剩余预算，再决定是否继续、降级或返回明确的服务结果。</p>
<p>可以将总耗时拆成：排队、读取状态、识别、检索或工具、提交与响应。只有拆开，才知道优化模型是否能改善用户体验。如果一半时间耗在排队，单独压缩模型推理的收益就有限。</p>
<p>重试会消耗剩余预算，还会增加系统负载。适合重试的通常是短暂网络或服务故障；参数错误、权限拒绝、状态冲突往往需要改变请求。指数退避和随机抖动可以避免大量客户端同步重试，但仍需设置次数上限，并确保操作适合重试。<a href="https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/">AWS 超时、重试与抖动说明</a></p>
<p>附件使用单调时钟在处理节点检查截止时间，超出预算则回滚。这是协作式预算检查：正在阻塞的网络调用仍需要客户端超时；CPU 推理若要强制取消，则需要可中断执行单元或进程隔离。请求中的预算字段需要配合这些执行机制，才能限制实际耗时。</p>
<h2>五、事务锁与并发控制</h2>
<p>示例代码把整个请求的本地处理放在同一事务中，便于演示状态与模拟订单如何一起提交。模型推理较慢时，事务会持续占用锁，因此高并发服务需要调整事务范围。</p>
<p>实际服务可以先读取状态快照，在事务外完成昂贵识别，再以 <code>expected_version</code> 做短事务提交。提交时必须重新核验版本；如果识别期间状态已经变化，就丢弃基于旧快照的结果或重新计算。</p>
<p>写工具还需要更细的任务状态，例如 proposed、confirmed、executing、succeeded、failed、unknown。<code>unknown</code> 表示结果尚待对账，不能当成明确失败。将这几个状态分开，恢复逻辑才知道应该重新调用、查询结果还是等待用户处理。</p>
<p>Python 标准库 HTTP 服务可用于本地演示；正式部署应选择适合认证、并发、连接管理与可观测性的服务器，并设置反向代理与资源限制。<a href="https://docs.python.org/3/library/http.server.html">http.server 文档</a></p>
<h2>六、设计缓存键与失效策略</h2>
<p>两个用户都输入“1”，可能选择不同订单；同一个用户前后输入“改成明天”，也可能修改不同任务。只按文本缓存识别结果，会把一个语境下的解释带到另一个语境。</p>
<p>缓存键应覆盖所有会影响结果的输入：当前文本、必要的历史或状态摘要、标签版本、模型与 Prompt 版本、语言，以及适当的用户或租户隔离信息。摘要不同可能导致判断不同，摘要策略版本也应纳入版本组合。</p>
<p>识别缓存与业务结果缓存还应分开。缓存一个政策分类候选，与缓存一笔订单的当前状态，具有不同的有效期和失效条件。更不能把“曾经确认过取消”当作可无限复用的确认缓存。</p>
<p>如果身份边界或状态依赖很难明确，先不缓存通常比使用错误缓存更容易维护。先测出成本热点，再决定缓存哪一层。</p>
<h2>七、服务监控与业务指标</h2>
<p>单看每秒请求数和错误码，很难发现模型把咨询路由成操作。可以把观测分成三个视角：</p>
<table>
<thead>
<tr>
<th>视角</th>
<th>关注内容</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>服务</td>
<td>延迟分布、超时、排队、依赖失败</td>
<td>定位运行瓶颈</td>
</tr>
<tr>
<td>决策</td>
<td>标签分布、拒识、澄清、升级模型比例</td>
<td>观察输入与策略变化</td>
</tr>
<tr>
<td>任务</td>
<td>完成、放弃、人工接管、重复与错误操作</td>
<td>判断实际用户结果</td>
</tr>
</tbody></table>
<p>每次请求记录标签、模型、Prompt、路由和状态版本，才能把变化对应到具体发布。日志中保存足以诊断的结构化字段，原始对话按明确的权限、脱敏和保留策略处理；不要默认把全部聊天与业务参数写入公共日志。</p>
<p>本地示例的审计表只保存会话与请求标识、版本、路由和耗时。生产环境可再连接 trace ID、工具操作 ID 和用户反馈，形成跨服务的请求链。</p>
<h2>八、错误样本复核与版本发布</h2>
<p>用户纠正“我不是要取消”，是有价值的错误信号，但仍要回看上下文：模型是否误解，界面是否展示不清，还是用户临时改变了主意？</p>
<p>可以先收集信号，再按错误类型分组，复核标签与预期动作，加入训练或回归数据。将线上所有失败自动标成某一类，会把工具故障、产品问题和用户改口混进识别训练。</p>
<p>发布时保存一组可回滚的版本：标签、数据、Prompt、模型、阈值和路由。影子模式只比较新旧输出，不执行新方案的业务写操作；灰度时逐步开放真实流量，并持续观察高代价错误。更新模型后，还需要验证完整业务流程。</p>
<h2>九、启动与测试 HTTP 服务</h2>
<pre><code class="language-bash">python batch3/service.py --db /tmp/intent-demo.sqlite3 --port 8765</code></pre><p>另一个终端发起请求：</p>
<pre><code class="language-bash">curl http://127.0.0.1:8765/turn \
  -H 'Content-Type: application/json' \
  -d '{"session_id":"demo","request_id":"r1","expected_version":0,"text":"取消订单 DEMO-A"}'</code></pre><p>系统先返回 <code>confirm</code>。用新的请求 ID、返回的版本和文本“确认”继续，才会完成模拟操作。重复发送完全相同的确认请求，会返回第一次保存的结果。</p>
<p>也可以直接运行自动检查：</p>
<pre><code class="language-bash">python batch3/tests.py
python batch3/http_smoke.py</code></pre><p>本次真实回环 HTTP 检查得到：提出方案与确认成功返回 200，原请求重放返回相同结果，旧版本新请求返回 409，同一 ID 换内容返回 409，多余字段返回 400。故障测试还检查了超时回滚、订单版本变化、费用条件变化和重启后的状态恢复。</p>
<p>下一篇<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-assistant-demo">完整助手项目</a>把标签、识别、状态、路由和这些服务约束连接起来，给出从安装到完整对话的运行路径。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184505676976885760</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>RAG 与 Agent 中的意图路由：检索、工具、闲聊与澄清</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/rag-agent-intent-routing</link>
    <pubDate>Wed, 23 Sep 2026 03:19:39 GMT</pubDate>
    <description>用户问“取消预订要收费吗”，需要查政策；用户说“查一下我的订单”，需要读取个人订单；用户说“免费就帮</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/rag-agent-intent-routing'>https://liuyaowen.cn/posts/ai-machine-learning/rag-agent-intent-routing</a></blockquote>
          <p>用户问“取消预订要收费吗”，需要查政策；用户说“查一下我的订单”，需要读取个人订单；用户说“免费就帮我取消”，需要先查事实，再判断条件，最后进入操作流程。三句话都围绕酒店和取消，处理路径却不同。</p>
<p>意图路由根据识别结果选择相应的服务或处理流程。它需要一起考虑用户目标、当前状态、参数和业务约束。本文以酒店助手为例，介绍知识检索、只读工具、写操作和澄清四类处理路径，代码见<a href="https://liuyaowen.cn/api/v3/objects/file/7y89ughjykqpraph4k.zip">完整工程</a>。</p>
<h2>一、意图识别与路由的分工</h2>
<p>分类器返回 <code>cancel_booking</code>，只说明用户表达了取消目标。路由层仍需知道取消哪笔订单、订单属于谁、是否允许取消、费用是多少，以及用户是否确认了当前方案。</p>
<p>如果这些问题全部交给标签处理，就会不断新增“取消但缺订单号”“取消且收费”“取消已经确认”等类别。标签数量会随流程状态迅速膨胀，标注也变得难以稳定维护。</p>
<p>更清楚的结构是：意图描述目标，槽位描述参数，状态描述当前进展，业务规则决定可用动作。路由层根据这些信息决定下一步操作，不能只按标签查找对应的处理函数。</p>
<h2>二、定义业务能力与意图映射</h2>
<p>能力目录说明系统实际上能提供哪些服务。一个意图可以映射到多步流程；多个咨询意图也可以共享同一个检索能力。</p>
<table>
<thead>
<tr>
<th>用户目标</th>
<th>标签</th>
<th>处理能力</th>
<th>返回依据</th>
</tr>
</thead>
<tbody><tr>
<td>早餐、入住或取消政策</td>
<td>policy_query</td>
<td>知识检索</td>
<td>文档内容与来源</td>
</tr>
<tr>
<td>查询房源与价格</td>
<td>room_search</td>
<td>只读房源工具</td>
<td>当前可用信息</td>
</tr>
<tr>
<td>查询个人订单</td>
<td>order_query</td>
<td>受权限约束的订单工具</td>
<td>具体订单事实</td>
</tr>
<tr>
<td>预订、修改、取消</td>
<td>对应业务标签</td>
<td>写操作流程</td>
<td>参数、核验、确认与执行结果</td>
</tr>
<tr>
<td>参数缺失或对象不明确</td>
<td>目标可能已知</td>
<td>澄清策略</td>
<td>当前状态与候选项</td>
</tr>
<tr>
<td>当前能力不支持</td>
<td>范围外状态</td>
<td>说明范围或转交</td>
<td>已发布的能力目录</td>
</tr>
</tbody></table>
<p>闲聊是否需要单独接收，取决于产品。综合助手可以把闲聊交给通用对话能力；本系列酒店助手则提供能力说明。不要因为每个输入都需要回复，就把闲聊伪装成某个酒店意图。</p>
<h2>三、知识检索与订单查询</h2>
<p>知识库适合保存相对稳定的规则和说明，例如早餐时间、入住政策、材料要求。个人订单状态则来自业务系统，带有用户归属、时效和访问权限。</p>
<p>把“我的订单取消了吗”送去普通文档检索，系统可能找到“如何取消订单”，却没有回答这笔订单实际处于什么状态。检索结果语义相关，不代表它拥有问题所需的事实。</p>
<p>因此可以先问两个问题：答案主要来自公共说明，还是来自当前用户的业务对象？如果是后者，先定位对象并调用受权限约束的工具；必要时再检索政策解释工具结果。</p>
<p>例如订单查询返回“取消费 50”，政策检索负责解释费用依据。两者共同构成答案，但不能用知识库里“部分房型可免费取消”的概括覆盖订单的具体费用。</p>
<h2>四、结合上下文改写查询</h2>
<p>用户问“那个能退吗”，直接拿这几个字检索通常信息不足。应先根据会话状态确认“那个”指的是哪笔订单或哪种产品，再生成明确的检索查询。</p>
<p>一种常见顺序是：</p>
<pre><code class="language-text">当前输入 + 对话状态
    → 判断目标与指代
    → 确定知识主题或业务对象
    → 在知识分支改写查询
    → 检索、组织证据、生成回答</code></pre><p>但这不是所有系统必须遵循的固定流水线。有些问题需要先做轻量检索才能确认能力，有些分类器本身也会使用检索到的标签示例。关键是区分检索的用途：它是在帮助选择标签，还是在为最终答案寻找证据？不同用途应有独立输入和评测。</p>
<p>改写时要保留否定和条件。“收费就别取消”不能被压缩成“取消订单”。对写操作尤其要避免把检索优化过程中生成的简短查询，再当成用户完整授权。</p>
<h2>五、写操作的校验与确认</h2>
<p>取消订单时，需要先识别取消意图、确定订单，再查询状态和费用，最后向用户展示方案并等待确认。</p>
<p>确认记录至少绑定操作类型、关键参数、对象版本和有效条件。例如用户确认的是“取消 DEMO-A，当前费用 0”，就不应在费用或订单对象变化后继续沿用旧确认。</p>
<p>附件的模拟流程保存待确认方案；用户回复“确认”后，再读取订单版本和费用。如果条件发生变化，返回冲突，让应用展示新方案。这样，识别结果、用户确认和真正提交之间有明确对应关系。</p>
<pre><code class="language-text">识别取消目标
  → 补齐订单号
  → 查询所属用户、状态和费用
  → 判断“免费才取消”是否成立
  → 展示方案
  → 用户确认
  → 重新核验关键事实
  → 提交并保存结果</code></pre><p>“确认参数”和“确认办理”也需要区分。用户回答“对，是这张订单”，可能只确认了对象，并没有确认取消费用与实际操作。产品应把确认问题说完整，让用户知道回复会触发什么。</p>
<h2>六、Workflow 与 Agent 的分工</h2>
<p>对于已知、稳定、需要严格约束的操作，固定 Workflow 很合适。取消订单有哪些检查、哪些条件必须成立，应由可检查的代码表达。</p>
<p>Agent 更适合处理路径不确定的目标，例如在多个信息来源之间补充查询、比较候选方案、组织不同工具返回的材料。它可以提出下一步，但工具权限、参数校验和写操作约束仍应由运行环境执行。</p>
<p>两者可以组合：Agent 负责理解复杂需求与收集证据，具体预订或取消由固定流程完成。这样既能保留灵活性，也能把关键业务约束集中在少数接口中。</p>
<p>选择方式时，看的是不确定性在哪里。如果只是表达多样，往往只需更强的识别器；如果处理路径也需要探索，才更需要动态规划。不能因为入口用了 LLM，就让每一次订单修改都变成自由规划。</p>
<h2>七、多任务的依赖与执行顺序</h2>
<p>“查订单，如果免费就取消，同时看看明天的房间”包含多个目标。路由层应保留各自任务 ID、参数和依赖，而不是将整个输入压成一个标签。</p>
<p><a href="https://liuyaowen.cn/posts/ai-machine-learning/multi-intent-recognition-task-dependencies">任务依赖文章</a>中的图，可以先执行订单查询，把结果变成可信事实；取消节点检查这个事实，查房节点则根据它自己的依赖继续处理。</p>
<p>如果取消条件不成立，只跳过取消节点。查房不依赖取消成功，就不应该一起停止。反过来，某个任务必须依赖前一步成功时，也不能仅因“前一步已经返回”就继续。</p>
<p>附件的 <code>batch3/planner.py</code> 用模拟订单结果推进这张图：只读节点返回结果，写节点返回待确认方案。程序根据查询结果选择后续节点，需要确认的写操作会暂停等待。</p>
<h2>八、权限控制与提示词注入防护</h2>
<p>知识文档可能包含“忽略前面的要求”之类文本，用户也可能在需求中声称自己有管理员权限。这些内容可以作为待分析的数据，却不能改变服务端的访问控制或工具能力。</p>
<p>提示词注入之所以危险，是因为模型处理自然语言时可能混淆数据与指令。路由层应将身份、权限、能力白名单和写操作门槛保存在可信程序中，避免仅依靠模型自觉遵守。<a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/">OWASP 提示词注入说明</a></p>
<p>例如查询订单时，权限检查应使用服务端已认证身份。用户在聊天里输入“这张订单属于我”，不能成为读取依据。模型返回一个有效的订单 ID，也不证明用户有权访问它。</p>
<h2>九、异常处理与服务降级</h2>
<p>检索没有证据时，返回“当前资料不足”并引导补充；订单工具超时时，进入有限重试或服务降级；目标不明确时，提出具体问题。这三种结果不能都被压成“没听懂”。</p>
<p>服务降级也不应随意改变目标。如果取消工具不可用，可以保存待办理状态并说明结果尚未确定；不能为了给出流畅回复，换成一段取消政策说明后声称任务完成。</p>
<p>工程中的典型路由结果包括 <code>knowledge</code>、<code>read_tool</code>、<code>clarify</code>、<code>confirm</code>、<code>condition_false</code> 和 <code>mock_write</code>。只有最后一种表示模拟业务写入已经完成，其他结果都各自对应明确的下一步。</p>
<p>从工程根目录运行：</p>
<pre><code class="language-bash">python batch3/assistant.py
python batch3/planner.py</code></pre><p>更完整的检索、Agent 运行与流式服务设计，可以回看<a href="https://liuyaowen.cn/posts/agent-llm-engineering/production-rag-agent-system-design">生产级 RAG 与 Agent 系统</a>。本篇关注请求为什么走向某种能力；下一篇<a href="https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service">服务上线</a>继续讨论同一条请求怎样可靠完成。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/rag-agent-intent-routing#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184505670677041152</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>意图识别小模型实战：分类头训练、知识蒸馏与低成本部署</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning</link>
    <pubDate>Wed, 23 Sep 2026 03:19:38 GMT</pubDate>
    <description>业务标签稳定、请求量增加以后，可以考虑用小模型处理高频的意图分类任务，减少通用模型的调用。选择小模型</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning'>https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning</a></blockquote>
          <p>业务标签稳定、请求量增加以后，可以考虑用小模型处理高频的意图分类任务，减少通用模型的调用。选择小模型之前，需要明确它要区分哪些类别，以及这些类别之间的差别。</p>
<p>本文先冻结句向量编码器，训练分类头，再通过知识蒸馏训练一个基于字符特征的轻量分类器，比较两者的分类效果、推理耗时和权重大小。训练脚本、配置、学生权重与结果均在<a href="https://liuyaowen.cn/api/v3/objects/file/7y89ughjykqpraph4k.zip">配套工程</a>中。</p>
<p></p>
<h2>一、理解训练目标</h2>
<p>编码器把文本变成向量，分类头把向量变成标签分数。编码器提取文本特征，分类头根据这些特征区分业务类别。</p>
<p>设编码器输出 <code>h = encoder(text)</code>，线性分类头为：</p>
<pre><code class="language-text">z = W h + b
p = softmax(z)
L = -log p[正确类别]</code></pre><p>训练分类头时，梯度更新 <code>W</code> 和 <code>b</code>，编码器保持不变。这样可以先把训练文本编码一次，随后反复训练很小的矩阵。通过这种方法，可以较快地验证现有文本向量能否区分这些类别。</p>
<p>如果编码器生成的向量难以区分咨询和操作，仅训练分类头可能不够。此时可以解冻编码器的部分层、训练适配器，或进行全参数微调，让表示也适应目标任务。更新的参数越多，通常越需要足够的训练数据，并合理设置学习率，避免模型丢失原有能力。Sentence Transformers 的训练文档也强调，不同任务需要的“相似”并不完全相同。<a href="https://www.sbert.net/docs/sentence_transformer/training_overview.html">训练文档</a></p>
<p>本文实际执行的是<strong>冻结编码器、训练分类头</strong>。编码器沿用系列中的多语言 MiniLM 句向量模型；新分类头有 3,080 个可训练参数。完整推理仍需要运行编码器，不能把分类头参数量当成整个模型大小。</p>
<h2>二、选择数据集</h2>
<p>本实验选用 PolyAI 发布的 BANKING77 数据集，从银行卡相关问题中选择八类：卡片到达、预计送达、卡片不可用、非接触支付不可用、支付被拒、支付处理中、不认识的支付、重复扣款。这些类别的主题接近，适合检验模型能否区分相似意图。数据来自原作者仓库，按 CC BY 4.0 保留署名和许可。<a href="https://github.com/PolyAI-LDN/task-specific-datasets">BANKING77 数据仓库</a></p>
<p>保留这八类的官方测试划分，共 320 条；从官方训练划分按规范化文本的固定哈希划分训练集与验证集，分别为 870 条和 197 条。拆分规则、原仓库提交和文件哈希保存在下载包中。</p>
<p>本节用英文银行问题演示训练方法；酒店助手沿用前文的中文标签。两套数据的训练和评测结果分别记录。</p>
<p>类别选择和超参数在训练前固定。验证集用于选择训练轮数，测试集只参与最终比较。最近邻索引和字符词表也都从训练数据建立。</p>
<h2>三、训练分类头</h2>
<p>“卡片没有送到”和“卡片通常多久送到”词面很接近，但一个偏向当前配送问题，一个询问预计时间。编码器先将文本转换为向量，分类头再根据标注学习两类问题的差别。</p>
<p>如果某个维度更常出现在配送状态问题上，训练会调整相应类别的权重；预测错时，交叉熵推动正确类别分数上升，并压低竞争类别。这个过程同时使用所有类别的信息，并非给每类维护一个关键词表。</p>
<p>核心训练代码如下。完整代码还会根据验证集选择模型，设置随机种子并保存结果：</p>
<pre><code class="language-python">head = torch.nn.Linear(384, 8)
optimizer = torch.optim.AdamW(head.parameters(), lr=0.03, weight_decay=0.01)

logits = head(train_embeddings)
loss = torch.nn.functional.cross_entropy(logits, train_labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()</code></pre><p>只训练分类头时，<code>train_embeddings</code> 可以缓存。解冻编码器以后则不能继续沿用不变的向量缓存，因为每轮更新都会改变文本表示。</p>
<p>处理类别不平衡时，先看每类样本和错误分布。重采样或设置类别权重可以提高低频类别对训练结果的影响，但也会改变模型面对真实频率时的行为。困难负例应围绕相近目标构造，而不只是随意加入大量无关文本。</p>
<h2>四、知识蒸馏的原理与实现</h2>
<p>假设一个教师对某句输入给出：配送状态 0.65、预计时间 0.30、其他类别共 0.05。硬标签只告诉学生“配送状态正确”；软分布还告诉学生，这句话与预计时间相近，而与其他类别更远。</p>
<p>蒸馏让学生同时学习人工标签与教师的相对判断。本文的学生使用字符 2 到 4 gram 的 TF-IDF 特征，最多 4,000 维，再接一个八类线性层。它不运行句向量编码器，因此推理路径更短。</p>
<p>训练目标写成：</p>
<pre><code class="language-text">L = α × CE(学生输出, 真实标签)
  + (1 - α) × T² × KL(教师软分布 || 学生软分布)</code></pre><p>温度 <code>T</code> 在 softmax 前缩放 logits，使分布更平缓，帮助学生学习次高类别之间的概率差异。教师与学生使用相同温度；常见实现再乘 <code>T²</code> 调整梯度量级。本文固定 <code>T=2</code>、<code>α=0.5</code>，同时训练一个只用硬标签的学生作为对照。<a href="https://docs.pytorch.org/tutorials/beginner/knowledge_distillation_tutorial.html">PyTorch 蒸馏教程</a></p>
<pre><code class="language-python">hard_loss = F.cross_entropy(student_logits, labels)
soft_loss = F.kl_div(
    F.log_softmax(student_logits / 2, dim=-1),
    F.softmax(teacher_logits / 2, dim=-1),
    reduction="batchmean",
)
loss = 0.5 * hard_loss + 0.5 * 4 * soft_loss</code></pre><p>教师参数保持冻结，软标签只从训练输入产生。保留真实标签损失很重要：教师也会判断错误，学生不应把教师的每个偏好都当作事实。</p>
<h2>五、比较分类效果与推理速度</h2>
<p>下表来自固定的 320 条测试输入。分类头和两个学生都运行了三个随机种子；本次三个种子的离散预测指标相同。表中延迟使用种子 42 的模型，在 CPU 预热后逐条处理 40 条文本，包含特征提取与分类：</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>正确数 / 320</th>
<th>Macro-F1</th>
<th>单条推理中位数</th>
</tr>
</thead>
<tbody><tr>
<td>句向量最近邻</td>
<td>301/320</td>
<td>0.941</td>
<td>本组未单列计时</td>
</tr>
<tr>
<td>冻结编码器 + 分类头</td>
<td>306/320</td>
<td>0.956</td>
<td>5.518 ms</td>
</tr>
<tr>
<td>学生：只用硬标签</td>
<td>289/320</td>
<td>0.903</td>
<td>0.099 ms</td>
</tr>
<tr>
<td>学生：加入蒸馏</td>
<td>298/320</td>
<td>0.931</td>
<td>0.091 ms</td>
</tr>
</tbody></table>
<p></p>
<p>只用硬标签的学生判断正确 289 条，加入蒸馏后为 298 条。教师判断正确 306 条，蒸馏后的学生仍有差距，但比只用硬标签训练的学生更接近教师。蒸馏学生与硬标签学生结构相同，推理时都不再调用教师；蒸馏增加的是训练阶段的监督信息。</p>
<p>选择部署方案时，需要同时比较分类效果和计算成本。两个学生模型的耗时都接近 0.1 毫秒，这次测量不足以说明同样结构的蒸馏学生速度更快。</p>
<h2>六、权重量化与存储压缩</h2>
<p>浮点权重通常使用 32 位表示；对权重按一定尺度映射到 8 位整数，可以减少存储。本文为每个输出类别分别计算尺度：</p>
<pre><code class="language-text">scale = max(abs(w)) / 127
q = round(w / scale)
近似恢复：w ≈ q × scale</code></pre><p>本次学生的压缩权重文件从 117,734 字节降到 29,342 字节，320 条测试输入上的最终分类指标保持一致。这里统计的是权重文件，不包含字符词表、IDF、Python 运行时和依赖。</p>
<p>附件的 int8 示例在加载后先恢复为浮点权重，再执行矩阵运算，因此展示的是<strong>存储压缩</strong>。真正的整数推理还需要支持相应算子的运行时；量化后是否更快，要重新测完整推理链，而不能由位宽直接推断。</p>
<p>量化影响的通常是靠近决策边界的输入。除了总分，还应检查预测变化的样本、低频类别和高代价动作。更激进的量化也可以使用校准数据或量化感知训练，但应与当前任务一起验证。</p>
<h2>七、模型部署与版本管理</h2>
<p>这个八类分类器总会输出一个已知标签。部署到真实入口以后，需要另行识别范围外请求，或者通过阈值、分差与其他判定模块决定是否接收。闭集分类训练不会自然赋予模型“这些类别都不适合”的能力。</p>
<p>增加标签时，还需要同步调整训练数据和部署配置。新旧类别边界、训练样本、类别顺序、模型输出和路由映射都要一起更新。历史模型可能依赖旧类别顺序；如果加载了新标签文件，分数位置与标签名称就会错位。</p>
<p>模型包至少保存标签列表、特征配置、权重、阈值策略、数据版本与运行依赖。更新时用固定回归集检查旧能力，再灰度观察新表达。如果教师来自 LLM 辅助标注，还要抽查标签定义是否被一致执行，尤其是咨询与操作的边界。</p>
<h2>八、运行训练与推理代码</h2>
<p>从工程根目录运行：</p>
<pre><code class="language-bash">python -m pip install -r batch2/requirements.txt
python batch3/prepare_banking.py
python batch3/train_small.py
python batch3/infer_small.py "My card was charged twice"
python batch3/infer_small.py "My card was charged twice" --int8-storage</code></pre><p>最后两条命令直接加载附带的学生权重，不需要运行教师。训练配置在 <code>batch3/training-config.json</code>，数据说明与许可在 <code>batch3/data/</code>，完整结果在 <code>batch3/results/small-model.json</code>。</p>
<p>选择小模型时，先明确要保留的能力和可接受的错误，再比较训练、蒸馏与部署方案。具体怎样把分类结果送进业务流程，见<a href="https://liuyaowen.cn/posts/ai-machine-learning/rag-agent-intent-routing">意图路由</a>；怎样持续观察模型变化，见<a href="https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service">服务上线与错误闭环</a>。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184505665010536448</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>意图识别怎么评测：从分类准确率到多轮任务成功率</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation</link>
    <pubDate>Wed, 23 Sep 2026 03:19:36 GMT</pubDate>
    <description>一个意图识别器的准确率从 90% 提升到 93%，并不一定意味着助手更好用了。多出来的正确结果可能全</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation'>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation</a></blockquote>
          <p>一个意图识别器的准确率从 90% 提升到 93%，并不一定意味着助手更好用了。多出来的正确结果可能全部来自高频咨询，而取消订单仍然经常判断错误；也可能模型变得更保守，大量请求被送去追问，被接收请求的准确率因此提高。</p>
<p>评测要回答一个更具体的问题：这次改动改善了哪种能力，付出了什么代价，又有没有让用户更容易完成任务？本文介绍分类、槽位、对话状态和业务结果的评测方法，并提供可以直接运行的评测脚本。<a href="https://liuyaowen.cn/api/v3/objects/file/7y89ughjykqpraph4k.zip">下载配套工程</a>。</p>
<h2>一、评测对象与指标</h2>
<p>酒店助手可以拆成四个环节：理解用户表达、更新对话状态、选择处理路径、执行业务流程。每个环节的正确答案不同，因此不能只用一个“准确率”覆盖所有问题。</p>
<p>用户说“入住改成明天”，意图识别可能判断为继续预订，这是分类层的答案；状态更新还要保留城市和房型，只修改入住日期，这是状态层的答案；最终预订还要满足日期、房源、价格和确认要求，这是任务层的答案。</p>
<p>如果只看最终是否订房成功，一旦失败，很难知道哪里出了问题。如果只看标签是否正确，又可能漏掉状态被覆盖、条件被丢弃和工具执行错误。合理的评测既保留分层诊断，也保留端到端结果。</p>
<table>
<thead>
<tr>
<th>层次</th>
<th>典型问题</th>
<th>指标或检查</th>
</tr>
</thead>
<tbody><tr>
<td>分类</td>
<td>咨询规则是否被误判为操作</td>
<td>每类召回、Macro-F1、混淆矩阵</td>
</tr>
<tr>
<td>拒识</td>
<td>不支持的请求是否被强行接收</td>
<td>覆盖率、接收错误率、范围外误接收</td>
</tr>
<tr>
<td>参数与状态</td>
<td>日期变化是否作用于正确任务</td>
<td>槽位匹配、状态完全匹配、任务绑定</td>
</tr>
<tr>
<td>多轮任务</td>
<td>用户目标是否真正完成</td>
<td>完成率、追问次数、恢复成功率</td>
</tr>
<tr>
<td>业务</td>
<td>是否产生错误或重复动作</td>
<td>误操作、重复执行、处理成本</td>
</tr>
</tbody></table>
<h2>二、划分训练集、验证集和测试集</h2>
<p>训练集用于学习参数，验证集用于挑选超参数和阈值，测试集用于在方案固定之后检查结果。三者的区别是参与决策的方式，而不仅是文件名。</p>
<p>假设一段原始对话生成了五种改写。把六条记录随机分进训练和测试，模型可能在测试中再次看到几乎相同的表达。对话、改写家族、用户或来源等关联关系，应先形成分组，再做划分。</p>
<p>例如，同一次会话里的“我要订北京酒店”和后面的“改成明天”不能分别放进训练和测试，否则无法检验模型对新对话的理解能力。测试所需的前文必须随整个对话保存。</p>
<p>实际操作可以先给每条记录加上 <code>conversation_id</code>、<code>source_id</code>、<code>family_id</code>，按任务选择合适的分组键。对已经上线的系统，还可以用时间划分观察新表达：较早的数据训练，后续一段时间的数据测试。时间划分也要防止同一工单或会话跨边界。</p>
<p>动态示例检索尤其容易被忽略。即使分类器没有训练过测试句子，只要 Few-shot 示例库或最近邻索引收录了测试答案，整个系统仍然使用了测试信息。检索库、Prompt 示例、阈值和模型都属于被评测方案的一部分。</p>
<h2>三、准确率与 Macro-F1</h2>
<p>准确率是正确预测数除以全部预测数，直观，但受类别频率影响。</p>
<p>设有 100 条请求，其中 90 条查询订单，10 条取消订单。系统全部猜成查询，准确率仍有 90%，取消类召回却是 0。对于取消流程，这个系统几乎没有提供识别能力。</p>
<p>对某个类别，先把预测结果看成“属于该类”与“不属于该类”两个集合：</p>
<pre><code class="language-text">Precision = TP / (TP + FP)
Recall    = TP / (TP + FN)
F1        = 2 × Precision × Recall / (Precision + Recall)
Macro-F1  = 各类别 F1 的算术平均</code></pre><p>Precision 关心选中的结果有多少是真的，Recall 关心真实存在的结果找回了多少。F1 同时考虑两者；Macro-F1 给每个类别相同权重，更容易暴露低频类别的问题。计算时需要固定标签列表，并明确没有预测或没有支持样本时如何处理。<a href="https://scikit-learn.org/stable/modules/generated/sklearn.metrics.f1_score.html">scikit-learn F1 文档</a></p>
<p>在上面的 90/10 例子中，查询类 F1 约为 0.947，取消类为 0，Macro-F1 约为 0.474。这组数字揭示了准确率掩盖的类别偏差。</p>
<p>但 Macro-F1 也不会自动表达业务代价。把咨询误判成取消，和把退款咨询误判成订单查询，后果可能不同。高代价类别仍应单列召回、误接收及业务流程结果。</p>
<h2>四、用混淆矩阵分析错误</h2>
<p>混淆矩阵的行表示真实类别，列表示预测类别。对角线上的数量是正确分类，其余格子告诉我们错误流向哪里。</p>
<p></p>
<p>例如，修改已有订单经常落入查房，就要检查模型是否只抓住了“房型”这个主题词，忽略“已经预订”的阶段信息。咨询取消规则经常落入取消操作，则需要检查正反例以及问句、条件和否定表达。</p>
<p>前一篇基础方法的预测结果，可以用 <code>batch3/evaluate.py</code> 重新计算。这里原始分类指标只统计 14 条已知类别输入，拒识指标另用完整的 21 条输入：</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>原始准确率</th>
<th>Macro-F1</th>
<th>策略接收数</th>
</tr>
</thead>
<tbody><tr>
<td>规则</td>
<td>0.571</td>
<td>0.477</td>
<td>10/21</td>
</tr>
<tr>
<td>TF-IDF 分类器</td>
<td>0.643</td>
<td>0.558</td>
<td>6/21</td>
</tr>
<tr>
<td>句向量近邻</td>
<td>0.714</td>
<td>0.695</td>
<td>4/21</td>
</tr>
</tbody></table>
<p>这张表不把拒识当作分类正确。候选排序与是否接收是两个决策，应该分开检查，否则无法判断某个方案是更会分类，还是只接收了更容易的请求。</p>
<h2>五、覆盖率与拒识效果</h2>
<p>考虑两套策略：A 接收 100 条，错 5 条；B 接收 10 条，错 0 条。如果只看接收正确率，B 更高。但剩下 90 条需要继续处理，系统可能让大量用户反复解释需求。</p>
<p>因此至少同时报告：</p>
<pre><code class="language-text">覆盖率 = 接收并选择标签的数量 / 全部输入数量
接收错误率 = 错误接收数量 / 接收数量
范围外误接收率 = 被接收的范围外数量 / 全部范围外数量</code></pre><p>没有接收任何样本时，接收错误率没有分母，应记为未定义。不能写成 0%，再把它当作优秀结果。</p>
<p>阈值扫描可以画出覆盖率与错误率的关系，但不能预设它一定平滑或单调。有限样本中，删掉某几个正确或错误结果，都可能使比例跳动。选阈值应依据验证数据与错误代价，固定后再检查测试表现；避免根据测试结果反复调整阈值。</p>
<h2>六、多标签、槽位与状态评测</h2>
<p>多标签任务可以把结果看成集合。预测 <code>{查订单}</code>，真值为 <code>{查订单, 退款咨询}</code>，属于漏识别；多预测一个取消任务，则是多识别。可以统计集合精确率、召回率，或要求整个集合完全一致。</p>
<p>但两个预订任务可能拥有相同标签。只比较标签集合会把它们合成一个。因此任务匹配应结合任务身份、关键参数和依赖关系，必要时先对预测节点与真值节点做匹配，再检查关系。</p>
<p>槽位匹配同样需要业务语义。“明天”和“2026-10-02”是否相等，取决于对话时间与时区；“北京市”和“北京”是否相等，取决于规范化规则。规范化必须统一应用于预测与真值，不能为了修复某个结果临时增加特殊规则。</p>
<p>状态完全匹配则检查这一轮结束后，所有受评字段是否正确。它比逐槽位平均更严格：城市、日期、房型中只错一个，整个状态就不匹配。两个指标一起看，可以分清局部字段进步和完整状态可用性。</p>
<h2>七、多轮任务的成功标准</h2>
<p>“助手说已经取消”不是取消成功的证据。成功标准应对应工具结果或业务状态，例如订单状态确实变为 cancelled，且没有产生重复动作。</p>
<p>同理，用户说“收费就不取消”，查询发现有费用后保留订单，应该算遵守目标，而不是简单算成取消失败。多轮评测需要先写清每个场景允许的终态。</p>
<p>可以为一个条件取消场景保存：初始订单、用户约束、允许操作、预期终态、禁止发生的动作。执行完对话后，同时检查结果和过程：是否追问了必要信息，是否展示了费用，是否错误取消，是否重复提交。</p>
<p>澄清也有成本。除了任务完成率，还应记录平均追问次数、同一缺口重复追问次数，以及用户在什么位置放弃。一个最终成功但需要十次重复解释的流程，仍有明确的改进空间。</p>
<h2>八、测量延迟与成本</h2>
<p>模型推理只占端到端耗时的一部分。用户感受到的还包括排队、状态读取、检索、工具调用和重试。</p>
<p>测量时先约定起点与终点：从 HTTP 请求到达开始，直到最终答复或明确的澄清结果返回。缓存命中、模型降级、失败请求和超时都要保留，不能只对成功返回的快请求算平均值。</p>
<p>平均值便于估算总资源，P95 等分位数帮助观察尾部体验。比较两个实现时，还需要保持并发数、输入长度、硬件和预热方式一致。调用成本则可以按每个完成任务统计，把重试和升级到大模型的额外调用算进去。</p>
<p>本系列本地工程把功能检查、模型分类实验与服务请求测试分别记录。三者回答不同问题：逻辑是否正确、模型如何分类、请求链能否按契约处理。</p>
<h2>九、根据评测结果改进系统</h2>
<p>先把错误分组：标签定义冲突、表达覆盖不足、上下文缺失、参数绑定错误、阈值过于保守、路由错误、工具故障。然后为某一种错误提出改动，并观察对应指标以及可能受影响的其他指标。</p>
<p>例如补充取消政策反例，应重点看咨询与取消之间的混淆；改变状态摘要，应重点看指代与短回复；提高阈值，应同时看错误接收和覆盖率。</p>
<p>多个随机种子帮助观察训练初始化的影响，但不能替代更多来源的测试数据。对多轮数据做重采样时，应以会话为单位，而不是把同一对话里的每一轮当作独立样本。使用模型评审答案时，也应先用人工检查过的样本核对评审标准，避免评审器只偏好熟悉的措辞。</p>
<p>运行本篇评测：</p>
<pre><code class="language-bash">python batch3/evaluate.py</code></pre><p>结果保存在 <code>batch3/results/hotel-evaluation.json</code>，包含每类指标、混淆矩阵和固定分差下的阈值扫描。下一步可以阅读<a href="https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning">小模型训练与蒸馏</a>，了解如何根据评测发现的问题确定训练目标。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184505656559013888</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>意图不明确时怎么办：未知意图识别、拒识与澄清追问</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification</link>
    <pubDate>Wed, 23 Sep 2026 02:37:12 GMT</pubDate>
    <description>意图识别器无法确定类别时，强行选择标签可能导致错误处理。用户问“开一张发票”，系统没有开票能力，却因</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification'>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification</a></blockquote>
          <p>意图识别器无法确定类别时，强行选择标签可能导致错误处理。用户问“开一张发票”，系统没有开票能力，却因为出现酒店相关词就把它送进订单查询；用户说“把那个取消”，系统没有确认是哪笔订单就继续操作。系统需要分别判断：请求是否超出业务范围，还是缺少确定目标所需的信息。</p>
<p>本文区分范围外请求、解释歧义、缺少参数与服务暂不可用，给出拒识和澄清的决策方法，并使用同一组教学数据实际选择阈值、观察覆盖率与错误。完整代码与数据可以<a href="https://liuyaowen.cn/api/v3/objects/file/ojwiqkt27icgck62v6.zip">下载复现</a>。</p>
<h2>一、区分不确定性的来源</h2>
<table>
<thead>
<tr>
<th>情况</th>
<th>例子</th>
<th>合理下一步</th>
</tr>
</thead>
<tbody><tr>
<td>目标明确，缺少槽位</td>
<td>我要订房</td>
<td>问城市、日期等具体参数</td>
</tr>
<tr>
<td>多个解释合理</td>
<td>把那个取消，但有两笔订单</td>
<td>问清是哪笔订单</td>
</tr>
<tr>
<td>当前能力不支持</td>
<td>酒店助手尚未提供开票</td>
<td>说明范围或转交</td>
</tr>
<tr>
<td>模型支持证据不足</td>
<td>含混输入，候选得分都低</td>
<td>请求重述或给出少量能力选项</td>
</tr>
<tr>
<td>服务暂不可用</td>
<td>意图明确，但查订单接口超时</td>
<td>解释服务问题，按策略重试或转交</td>
</tr>
</tbody></table>
<p>最后一种不是意图未知。若把工具故障标成范围外，后续错误分析就无法分清理解失败与服务失败。</p>
<p>范围外也不等于没有意义。酒店开票是合理需求，只是可能不在当前能力目录。CLINC 将已知类别分类与范围外输入识别作为需要分别检查的问题，可作为评测设计的参考。<a href="https://arxiv.org/abs/1909.02027">CLINC 论文</a></p>
<h2>二、理解候选分数</h2>
<p>假设分类器只有查询、预订、取消三个类别。无论输入是什么，输出的最大值都必然属于其中之一。若没有范围外判断或拒识策略，模型无法通过“没有合适选项”表达问题。</p>
<p>不同分数的含义也不同：</p>
<table>
<thead>
<tr>
<th>分数</th>
<th>可以说明什么</th>
<th>不能直接说明什么</th>
</tr>
</thead>
<tbody><tr>
<td>关键词命中数</td>
<td>命中了多少预设条件</td>
<td>语义判断的正确概率</td>
</tr>
<tr>
<td>余弦相似度</td>
<td>向量空间中的接近程度</td>
<td>该请求属于业务范围的概率</td>
</tr>
<tr>
<td>分类器概率</td>
<td>在模型假设与训练类别下的相对倾向</td>
<td>未经校准时的真实正确率，或域外概率</td>
</tr>
<tr>
<td>LLM 自报置信度</td>
<td>模型生成的一个数值</td>
<td>已经过统计校准的可靠概率</td>
</tr>
</tbody></table>
<p>概率校准研究的是预测分数与观察到的事件频率是否一致。校准效果依赖数据分布；当前输入与校准数据差异较大时，需要重新检查。<a href="https://scikit-learn.org/stable/modules/calibration.html">scikit-learn 概率校准文档</a></p>
<h2>三、最高分阈值与候选分差</h2>
<p>一种简单策略是先检查最高分是否足够，再检查前两名是否太接近：</p>
<pre><code class="language-python">from batch2.policy import decide

print(decide({"order_query": 0.30, "cancel_booking": 0.20}, 0.50, 0.10))
print(decide({"order_query": 0.80, "cancel_booking": 0.79}, 0.50, 0.10))
print(decide({"order_query": 0.80, "cancel_booking": 0.50}, 0.50, 0.10))</code></pre><p>在工程根目录运行，三个结果分别为 <code>abstain</code>、<code>clarify</code>、<code>select</code>。这组小数仅用于测试决策分支，不是推荐阈值。</p>
<p>最高分低意味着当前模型缺少足够支持，可能是范围外，也可能只是模型能力不足；前两名很接近意味着候选难以区分，但不一定存在真正的用户语义歧义。因此代码返回的是“拒绝选择候选”和“需要进一步区分”，不会直接将低分请求判定为范围外输入。</p>
<p>对于分数相同的并列候选，示例明确走澄清分支，避免标签顺序决定结果。非有限值也会被拒绝，防止 NaN 参与比较后意外通过。</p>
<h2>四、根据错误代价选择阈值</h2>
<p>本次教学实验在训练之外准备了 28 条验证样例：21 条属于 7 个已知类别，7 条属于当前范围外。用验证集搜索阈值和分差，然后将选定参数固定，在另外 21 条演示检查样例上运行。</p>
<p>为了展示取舍，预先使用如下损失：</p>
<pre><code class="language-text">损失 = 5 × 错误接收数 + 1 × 已知请求被拒绝选择的数量</code></pre><p>错误接收包含两种：接收了范围外输入，或将已知输入选成错误标签。拒识和澄清都视为本次没有选择标签；已知请求因此增加一次代价，范围外请求不选标签则不增加代价。</p>
<p>这个 5:1 的设定表达一种偏好：宁可多问一次，也要减少错误接收。取消订单误判通常比查询菜单误判代价更高，因此真实业务可以按动作分别设置代价，同时考虑追问造成的流失和人工处理成本。</p>
<p>先在验证集选择参数，再用另一组数据检查固定方案，可以避免根据最终结果反复调整阈值。具体网格与结果见下载包。</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>最高分阈值</th>
<th>分差阈值</th>
<th>检查集覆盖率</th>
<th>接收中的错误率</th>
</tr>
</thead>
<tbody><tr>
<td>关键词规则</td>
<td>0.00</td>
<td>0.00</td>
<td>47.6%（10/21）</td>
<td>30.0%（3/10）</td>
</tr>
<tr>
<td>TF-IDF + 逻辑回归</td>
<td>0.00</td>
<td>0.15</td>
<td>28.6%（6/21）</td>
<td>16.7%（1/6）</td>
</tr>
<tr>
<td>句向量近邻</td>
<td>0.80</td>
<td>0.10</td>
<td>19.0%（4/21）</td>
<td>0.0%（0/4）</td>
</tr>
</tbody></table>
<p>最高分阈值选成 0，并不意味着无条件接受。规则仍会拒绝零命中和最高分并列；TF-IDF 还需要通过候选分差。两项条件要一起理解。</p>
<p>例如 TF-IDF 在这组输入上只接收了 6 条。较低的覆盖率意味着更多请求进入追问或其他处理路径。评价这套策略时，应同时考虑错分代价和后续处理成本，不能只看单个指标。</p>
<h2>五、阈值方法的局限</h2>
<p>高阈值可以减少部分弱匹配的误接收，却也会拒绝表达自然但与训练例句相距较远的正确请求。如果错误输入和正确输入的分数分布大量重叠，单个阈值无法同时保留高覆盖和低错误。</p>
<p>同理，候选分差只比较现有类别之间的相对关系。一个范围外请求可能与所有类别都不匹配，但仍然明显更像其中某一类；一个已知请求也可能因为标签定义重叠而分差很小。</p>
<p>遇到这些情况，应检查标签边界、困难负例、表示模型和训练数据，必要时增加范围判断模块或改变路由。反复细调阈值可能只会造成验证集过拟合。</p>
<h2>六、设计具体的澄清问题</h2>
<p>“你想干什么”通常浪费了已经获得的信息。澄清时应利用已经获得的信息，只询问仍未明确、且会影响后续处理的内容。</p>
<table>
<thead>
<tr>
<th>已知情况</th>
<th>不够有效的追问</th>
<th>更具体的追问</th>
</tr>
</thead>
<tbody><tr>
<td>已知用户要订房，缺日期</td>
<td>你需要什么服务</td>
<td>你想哪天入住、哪天退房</td>
</tr>
<tr>
<td>订单 A 与 B 都可能被指代</td>
<td>请补充信息</td>
<td>你要取消 DEMO-A 还是 DEMO-B</td>
</tr>
<tr>
<td>咨询规则与执行取消难区分</td>
<td>请再说一遍</td>
<td>你是先了解取消费用，还是要办理取消</td>
</tr>
<tr>
<td>“换一个”对象不清楚</td>
<td>你说的是什么意思</td>
<td>你想换酒店、日期还是房型</td>
</tr>
<tr>
<td>系统没有开票能力</td>
<td>请选择查询、预订或取消</td>
<td>当前助手不支持开票，可以联系酒店前台；需要的话我能继续查订单</td>
</tr>
</tbody></table>
<p>这些问题的具体选项必须来自当前状态或真实候选。模型如果编造了不存在的订单号，追问本身也会造成误导。</p>
<p>澄清还需要更新状态。例如用户选了订单 A，系统应将订单槽位绑定到当前取消任务，并使旧的候选问题失效。发出追问时应同时保存待回答问题，确保下一轮能够继续处理。</p>
<h2>七、设置追问上限</h2>
<p>连续两次没有解决同一个缺口，继续用同样的句子追问通常不会改善体验。附件提供一个小型策略函数，区分缺槽、歧义、低支持和不支持能力，并演示次数上限：</p>
<pre><code class="language-python">from batch2.policy import clarification_action

assert clarification_action("missing_slot", attempts=0) == "ask_specific_slot"
assert clarification_action("ambiguous", attempts=2) == "offer_handoff_or_stop"
assert clarification_action("ambiguous", attempts=2, user_cancelled=True) == "cancel_dialogue_task"</code></pre><p>这里默认上限为 2 次，只用于展示策略分支。生产系统应按缺口或任务记录次数，在问题解决后重置，并结合用户状态决定是否提供人工入口或结束任务。该函数没有实现完整的会话计数器与人工系统。</p>
<p>用户明确放弃当前任务时，应优先尊重这一决定；应停止收集该任务的参数。“不订了”取消的是对话中的预订任务，不能自动转换成取消一笔真实订单。</p>
<h2>八、评估拒识效果</h2>
<p>只报告“被接收样本中的正确率”可能掩盖大量拒识。需要同时给出覆盖率、接收中的错误率，以及范围外输入被误接收的数量。</p>
<pre><code class="language-text">覆盖率 = 接收并选择标签的样本数 / 全部样本数
选择性错误率 = 错误接收数 / 接收样本数</code></pre><p>当接收样本为零时，选择性错误率没有分母，应记为未定义，而不是 0%。一个全部拒绝的系统可以没有错误动作，却不代表提供了可用服务。</p>
<p>还应单独检查已知类别的误拒比例、每种高代价操作的误接收、澄清后的成功率和平均追问次数。单轮阈值实验无法回答完整多轮任务是否完成，这部分留给后续评测文章。</p>
<h2>九、取消订单的澄清示例</h2>
<p>用户说“把那个取消”，系统里有两笔订单。此时取消目标可能明确，但对象不唯一，应追问“你要取消 DEMO-A 还是 DEMO-B？”用户选择 A 后，系统还需核实取消费用。如果用户补充“收费就不取消”，要将条件写入任务，而不是继续沿用无条件取消。</p>
<p>若查询费用失败，问题转为服务不可用；若查询发现有费用，问题转为条件不成立。这两种情况都不应再次让用户解释自己的意图。每次追问只补充当前缺少的信息，系统再根据回复更新状态并选择后续流程。</p>
<p>例子也说明，澄清成功率不能只看用户有没有回复。更有用的判断是：回复是否消除了原来的歧义，是否正确更新了状态，是否减少了错误动作。</p>
<p>拒识表示当前无法可靠选择类别。系统应根据原因决定是继续追问、说明能力范围，还是转交其他服务。</p>
<p>继续阅读：<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-rules-vectors-baselines">基础方法实测</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design">标签设计</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/multi-turn-intent-recognition-context">多轮状态</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/202503211">系列总览</a>。</p>
<h2>进阶阅读</h2>
<ul>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation">意图识别怎么评测：从分类准确率到多轮任务成功率</a>。</li>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/production-intent-recognition-service">意图识别服务上线：延迟、成本、监控与错误闭环</a>。</li>
</ul>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184494987054944256</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>一句话多个意图怎么办：多标签识别、条件与任务依赖</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/multi-intent-recognition-task-dependencies</link>
    <pubDate>Wed, 23 Sep 2026 02:37:12 GMT</pubDate>
    <description>“查一下订单，如果可以免费取消就取消，再看看明天的房间。”这句话里至少有查询订单、取消订单和查询房间</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/multi-intent-recognition-task-dependencies'>https://liuyaowen.cn/posts/ai-machine-learning/multi-intent-recognition-task-dependencies</a></blockquote>
          <p>“查一下订单，如果可以免费取消就取消，再看看明天的房间。”这句话里至少有查询订单、取消订单和查询房间三个目标。如果识别器只返回三个标签，系统仍然不知道先做什么、什么情况下取消，以及取消失败后是否继续查房。</p>
<p>本文介绍如何用任务图表示复合意图中的任务、参数、依赖和条件，并提供校验与回放代码。通过这些例子，可以了解多标签分类与任务规划的区别，以及先后、条件和备选关系的表示方法。</p>
<p><a href="https://liuyaowen.cn/api/v3/objects/file/ojwiqkt27icgck62v6.zip">下载任务图 Schema、示例与测试代码</a>。它与<a href="https://liuyaowen.cn/posts/ai-machine-learning/multi-turn-intent-recognition-context">多轮状态文章</a>共用酒店业务标签，但使用独立的 <code>task-graph/2.0</code> 契约，保留第一批接口不变。</p>
<h2>一、区分多参数、多意图与同类多任务</h2>
<table>
<thead>
<tr>
<th>输入</th>
<th>实际结构</th>
<th>不应怎样处理</th>
</tr>
</thead>
<tbody><tr>
<td>订北京酒店，明天入住，住两晚</td>
<td>一个目标、多个参数</td>
<td>给每个参数都建一个意图</td>
</tr>
<tr>
<td>查订单，也告诉我早餐时间</td>
<td>两个不同目标</td>
<td>只保留最后一句</td>
</tr>
<tr>
<td>北京订一间，上海也订一间</td>
<td>两个同类任务</td>
<td>用标签集合去重成一个预订</td>
</tr>
</tbody></table>
<p>多标签分类通常回答哪些类别存在；任务抽取还需要回答有几个任务、各自参数是什么、彼此有什么关系。两者可以协作，但输出的信息量不同。</p>
<p>第一批契约使用意图数组下标绑定参数，并禁止重复标签。它可以表示部分多意图请求，但无法完整表示两个独立的预订任务。本篇增加稳定任务 ID，让标签描述任务类型，ID 标识具体任务。</p>
<h2>二、任务之间的依赖关系</h2>
<p>复合请求常见几种关系：并列、先后、条件和备选。</p>
<p>“查订单，也查房间”可以是并列；“先查订单，再告诉我它的取消规则”有信息依赖；“免费才取消”有业务条件；“没双床就看看大床”则有备选分支。</p>
<p>列表排在前面，不一定意味着后一个必须等前一个成功。数据库查询可以并行，但展示顺序仍然固定；反过来，模型输出数组里相邻的两个任务，可能有非常强的依赖。</p>
<p>因此应把依赖显式写出来。也应保留不确定性：“取消以后再查房”究竟要求取消成功，还是只要求取消流程结束？如果这个差异影响业务行为，就需要追问或由明确的产品约定处理，程序不应自行假定用户选择了哪一种处理方式。</p>
<h2>三、构建任务图</h2>
<p>为了消除“再看看”的歧义，示例采用明确表达：</p>
<blockquote>
<p>先查订单 DEMO-A。如果可以免费取消，就取消。无论是否取消，都查一下上海明天的房间。</p>
</blockquote>
<p>任务关系如下：</p>
<p></p>
<p>三个任务分别保存参数。取消任务依赖查询结果；查房也在查询之后进行，但不会因取消条件不成立被跳过。完整结构如下：</p>
<pre><code class="language-json">{
  "schema_version": "task-graph/2.0",
  "status": "known",
  "question": null,
  "tasks": [
    {
      "id": "lookup",
      "intent": "order_query",
      "params": {"order_id": "DEMO-A"},
      "depends_on": [],
      "guard": null
    },
    {
      "id": "cancel",
      "intent": "cancel_booking",
      "params": {"order_id": "DEMO-A"},
      "depends_on": ["lookup"],
      "guard": {
        "source_task": "lookup",
        "field": "can_cancel_free",
        "equals": true
      }
    },
    {
      "id": "rooms",
      "intent": "room_search",
      "params": {"city": "上海", "check_in": "明天"},
      "depends_on": ["lookup"],
      "guard": null
    }
  ]
}</code></pre><p>“明天”保留为用户表达，进入真实查询之前仍需按会话时间和时区归一化。图中的参数只表示识别到的信息，不证明业务参数已经完整有效。</p>
<h2>四、验证执行条件</h2>
<p>第一批用自然语言保存条件，避免识别时丢失约束。本篇对一个具体条件进一步结构化：从订单查询结果读取布尔字段 <code>can_cancel_free</code>，与 <code>true</code> 比较。</p>
<p>这是教学业务接口的约定，不是所有酒店 API 都有的通用字段。真实应用可能需要同时查询订单产品、取消政策、当前时间和费用，再计算这个事实。计算结果需要可靠的数据来源和有效期，不能仅依据用户所说的“免费”。</p>
<p>代码不会执行任意条件字符串，也不支持模型生成 Python 表达式。当前只允许订单查询提供这个条件，并检查查询与取消指向同一订单，防止错误使用其他订单的查询结果。</p>
<p>条件有三种状态：已知为真、已知为假、尚无证据。“没有查到”不等于 false，也不等于默认 true。第三种状态应等待、重查或澄清，而不是直接进入执行。</p>
<h2>五、任务图的结构与语义校验</h2>
<p>JSON Schema 可以约束字段与类型，但仅靠它难以表达所有跨节点关系。附件的校验器检查：</p>
<ul>
<li>任务 ID 唯一，标签与参数属于允许目录。</li>
<li>依赖引用的任务存在，不能依赖自己。</li>
<li>图中不存在循环依赖。</li>
<li>条件来源必须是显式依赖，并且具备对应的事实能力。</li>
<li>免费取消的事实必须与取消任务指向同一订单。</li>
<li>未能明确解释的请求，只返回澄清问题，不混入待执行任务。</li>
</ul>
<p>例如 A 等 B、B 又等 A，程序永远无法开始。又如条件引用一个不存在的 <code>lookup2</code>，即使 JSON 格式正确，也不可能得到证据。</p>
<p>结构正确与语义正确需要分别检查。比如模型漏掉“不要取消”，仍可能生成一张内部一致的取消任务图。结构校验负责发现坏引用和循环，语义校验负责确认任务是否忠实表达了用户需求。</p>
<h2>六、为同类任务分配独立 ID</h2>
<p>“北京订一间，上海再订一间”的结构可以是：</p>
<pre><code class="language-json">{
  "schema_version": "task-graph/2.0",
  "status": "known",
  "question": null,
  "tasks": [
    {"id": "beijing", "intent": "book_room", "params": {"city": "北京"}, "depends_on": [], "guard": null},
    {"id": "shanghai", "intent": "book_room", "params": {"city": "上海"}, "depends_on": ["beijing"], "guard": null}
  ]
}</code></pre><p>这里按明确的先后解释“再”；如果业务不应要求北京预订成功才能安排上海，则应先确认或改用不同依赖语义。实际系统应区分“等待完成”和“等待成功”，本文的最小示例只把 <code>completed</code> 视为调用方确认成功完成的任务集合。</p>
<p>跨轮修改时，任务 ID 更重要。用户说“上海那间改成双床”，应该更新上海任务，不能仅凭标签 <code>book_room</code> 找到第一个预订就修改。</p>
<h2>七、处理否定、冲突与备选分支</h2>
<p>“不要取消，只查订单”应只生成查询任务。把取消节点放入图，再给它加一个永远为假的条件，会增加误用机会，也把明确否定混成了业务条件。</p>
<p>“取消 DEMO-A，同时把它改到明天”涉及同一订单的冲突目标。除非用户明确表达了备选或先后意图，否则应该澄清。例如询问“你希望取消这笔订单，还是保留订单并改期？”</p>
<p>“有双床就订双床，没有就订大床”需要有优先级且互斥的备选分支。当前教学图没有实现通用 AND、OR、ELSE，也没有实现分支汇合。需要这些能力时，应扩展版本化契约和测试；两个预订节点必须设置互斥条件，否则可能同时下单。</p>
<h2>八、运行条件分支示例</h2>
<p>在解压后的工程根目录执行：</p>
<pre><code class="language-bash">python3 batch2/task_graph.py
python3 batch2/tests.py</code></pre><p>第一条命令使用人工构造的任务图，分别回放条件证据缺失、为假和为真的情况：</p>
<table>
<thead>
<tr>
<th>can_cancel_free</th>
<th>cancel 节点</th>
<th>rooms 节点</th>
</tr>
</thead>
<tbody><tr>
<td>缺失</td>
<td><code>waiting_evidence</code></td>
<td><code>read_candidate</code></td>
</tr>
<tr>
<td>false</td>
<td><code>skip_condition_false</code></td>
<td><code>read_candidate</code></td>
</tr>
<tr>
<td>true</td>
<td><code>requires_business_validation</code></td>
<td><code>read_candidate</code></td>
</tr>
</tbody></table>
<p>这张表来自实际运行结果。这里用模拟查询结果演示分支如何变化，代码不连接真实订单。</p>
<p>其中 <code>read_candidate</code> 只表示依赖已经满足，可以交给业务路由进一步处理；<code>requires_business_validation</code> 明确表明写操作还要验证参数、权限、费用和确认状态。任何字段都不代表订单已经取消。</p>
<h2>九、任务图、调度与执行的分工</h2>
<p>任务图描述用户希望完成什么，调度系统决定如何安排执行，工具服务负责具体动作与结果。把三者分开，可以分别检查漏识别、错误调度和工具故障。</p>
<p>真实服务还要补齐任务持久化、幂等键、失败重试、取消传播、结果过期、授权与执行审计。对于部分成功的请求，需要向用户解释哪些已完成、哪些失败；不能在一个节点失败后笼统地返回“操作失败”，也不能把整个计划宣称成功。</p>
<p>第一批的 <code>slot_updates</code> 与本篇任务图也不能直接混用。前者更新对话状态，后者描述复合需求结构。工程中可增加显式适配层，将识别到的任务 ID 绑定到状态与执行记录；接口字段的含义发生变化时，应更新版本并提供迁移说明。</p>
<h2>十、复合意图测试方法</h2>
<p>至少准备几组成对案例：有条件与无条件、条件为真与缺失、同标签的一个任务与两个任务、有顺序与无顺序、明确否定与真实取消、指代唯一与多个候选。</p>
<p>测试应分层进行。先检查识别器是否保留任务和关系，再检查校验器是否拒绝无效任务图，再用模拟事实验证分支。最后才验证真实工具执行、失败恢复和权限控制。用预设任务图做回放，不能替代第一层的模型识别测试。</p>
<p>复合意图识别需要记录每个任务的 ID、参数和依赖关系。任务图将这些信息明确表示出来，便于校验，也便于发现需要向用户澄清的内容。</p>
<p>继续阅读：<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design">标签体系设计</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/multi-turn-intent-recognition-context">多轮状态与任务恢复</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification">未知意图与澄清</a>。</p>
<h2>进阶阅读</h2>
<ul>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/rag-agent-intent-routing">RAG 与 Agent 中的意图路由：检索、工具、闲聊与澄清</a>。</li>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-assistant-demo">从零实现一个多轮意图识别助手：语料、状态、路由与评测</a>。</li>
</ul>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/multi-intent-recognition-task-dependencies#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184494985138147328</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>意图识别模型怎么选：规则、TF-IDF、向量检索与小模型基线</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-rules-vectors-baselines</link>
    <pubDate>Wed, 23 Sep 2026 02:37:05 GMT</pubDate>
    <description>实现意图识别时，可以先比较三种基础方法：关键词规则、TF-IDF 加线性分类器、句向量近邻。它们分别</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-rules-vectors-baselines'>https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-rules-vectors-baselines</a></blockquote>
          <p>实现意图识别时，可以先比较三种基础方法：关键词规则、TF-IDF 加线性分类器、句向量近邻。它们分别依赖显式条件、业务标注和预训练语义表示，可以作为后续优化的基线。</p>
<p>本文用同一组中文酒店教学数据运行三个识别器，公开数据划分、实现参数、阈值选择和逐条预测。通过对比，我们会看到三种方法为什么会判断不同，以及怎样根据错误选择下一步优化。</p>
<p><a href="https://liuyaowen.cn/api/v3/objects/file/ojwiqkt27icgck62v6.zip">下载代码、数据和完整实验结果</a>。标签定义见<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design">意图标签设计</a>，复杂多轮样例见<a href="https://liuyaowen.cn/posts/ai-machine-learning/complex-intent-recognition-corpus">48 条语料专题</a>。</p>
<h2>一、任务定义与数据准备</h2>
<p>本次只识别单轮句子的业务类别，不提取槽位、不恢复历史，也不执行工具。使用 7 个标签：政策咨询、查房、预订、订单查询、修改预订、取消预订、退款咨询。</p>
<p>为比较三种方法，准备三份用途不同的数据：</p>
<table>
<thead>
<tr>
<th>文件</th>
<th>组成</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>train.jsonl</td>
<td>42 条，7 类各 6 条</td>
<td>拟合 TF-IDF 与分类器，建立向量示例库</td>
</tr>
<tr>
<td>validation.jsonl</td>
<td>21 条已知类别 + 7 条范围外</td>
<td>选择分数阈值和候选分差</td>
</tr>
<tr>
<td>probe.jsonl</td>
<td>14 条已知类别 + 7 条范围外</td>
<td>参数固定后的演示检查</td>
</tr>
</tbody></table>
<p>下文用这组教学数据解释方法差异。完整数据与运行环境见下载包 README。</p>
<p>训练集用来学习，验证集用来选阈值，检查集用来观察固定方案的行为。将这三种用途分开，才能知道改善来自哪里。</p>
<h2>二、统一识别器接口</h2>
<p>三个识别器都实现 <code>score(texts)</code>，返回每句输入对应的标签到分数的映射。它们的分数含义不同：规则输出是否命中，TF-IDF 分类器输出概率，句向量输出与该类示例的最高余弦相似度。</p>
<p>这些方法使用相同的返回结构，但分数的含义和取值尺度不同。不能因为两个方案都返回 0.7，就认为可靠程度相同。最终选不选标签，由各自的验证数据与决策策略决定。</p>
<p>候选接口也不直接替代第一批的完整结构化输出。缺少槽位与上下文的分类器，不应返回未经提取或推断的字段值。后续路由可以先使用候选，再调用参数提取或对话状态模块。</p>
<h2>三、关键词与正则规则</h2>
<p>规则实现很简单：每个类别有若干正则表达式，只要命中其中一个，就返回 1，否则返回 0。例如“查.*订单”用于订单查询，“退款”用于退款咨询。</p>
<p>规则通过文本模式选择分类分支。它很容易解释，也容易执行，但模式本身通常不知道否定修饰哪个动作、条件约束哪个目标。</p>
<p>“不要取消，只查订单”可能同时命中两个类别。本文在并列最高分时选择澄清，而不是由字典顺序决定最终动作。没有命中时也不默认返回第一个标签。</p>
<p>生产规则可以更细，但要监控规则互相遮蔽、顺序变化和新增例外的影响。增加规则后仍需测试，确认是否提高了覆盖率、是否引入新的冲突。</p>
<h2>四、TF-IDF 与逻辑回归分类</h2>
<p>中文词语之间通常没有空格，本例直接使用字符 n-gram 提取特征。本例使用 2 到 4 个字符的连续片段，并在训练数据上拟合词表、IDF 和逻辑回归分类器：</p>
<pre><code class="language-python">import json
from pathlib import Path
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

train = [json.loads(line) for line in Path("batch2/data/train.jsonl").read_text().splitlines()]
model = make_pipeline(
    TfidfVectorizer(analyzer="char", ngram_range=(2, 4), min_df=1),
    LogisticRegression(C=4.0, class_weight="balanced", max_iter=1000, random_state=42),
)
model.fit([row["text"] for row in train], [row["label"] for row in train])
print(dict(zip(model.classes_, model.predict_proba(["查一下酒店订单"])[0])))</code></pre><p>字符片段可以捕捉“退款”“到账”“订单”等线索，也可能学习到模板、订单号前缀等偶然特征。真实实验应检查脱敏方式和实体替换后的表现，检查模型是否依赖了订单号格式等无关特征。</p>
<p>这里的 TF-IDF 把文本转成稀疏特征，逻辑回归学习每个特征对不同标签的影响。<a href="https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html">TfidfVectorizer 文档</a></p>
<p>TF-IDF 可以拆成两个量理解。TF 描述一个片段在当前文本中的出现情况，IDF 降低到处都出现的片段权重。若“酒店”几乎每个类别都出现，它提供的区分信息很少；“到账”集中出现在退款类中，往往更有帮助。</p>
<p>逻辑回归再对这些特征加权：每个类别计算 <code>z_k = w_k · x + b_k</code>，多类别模型把这些分数通过 softmax 转成分布。训练时根据标注修正权重，使正确类别获得更高分。它与手写规则的差别在于，特征对类别的正负作用由数据学习，而不完全依靠开发者逐条规定。</p>
<p>字符 n-gram 也解释了它的弱点。“退回款项”和“钱什么时候到账”可能缺少相同片段；同义表达跨度大时，稀疏特征很难自行建立关联。把 n-gram 拉长可以保留局部语序，却会增加稀疏性；太短又容易失去短语意义。</p>
<p>模型的 <code>predict_proba</code> 输出也不能自动解决范围外输入。已知类别上的概率分布仍然总和为 1，一个完全无关的请求也会有最大类别。需要另加拒识实验，而不是直接最大值路由。</p>
<h2>五、句向量与近邻分类</h2>
<p>本例加载多语言句向量模型 <code>sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2</code>，在 CPU 上运行，并固定模型仓库 revision。训练例句与查询都做向量归一化，然后用点积计算余弦相似度。</p>
<p>该模型输出 384 维表示，让每句话变成一个固定长度的向量。<a href="https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2">模型卡</a></p>
<pre><code class="language-text">训练例句 → 编码、归一化 → 示例向量矩阵
当前输入 → 编码、归一化 → 与示例计算相似度
                               ↓
                同一类别取最高示例分数
                               ↓
                 分数阈值与候选分差判断</code></pre><p>Sentence Transformers 提供句子编码与相似度接口，具体比较方式与归一化配置应保持一致。<a href="https://sbert.net/docs/sentence_transformer/usage/semantic_textual_similarity.html">相似度文档</a></p>
<p>归一化后的向量长度为 1，点积就等于余弦相似度。模型将语义相近的表达映射到相近方向，因此“钱何时回来”和“退款多久到账”即使字面重合较少，也可能靠近。这里的关键能力来自编码器预训练，而不来自当前 42 条例句的训练。</p>
<p>但语义相近不等于意图相同。“取消订单”和“取消订单会收费吗”主题非常接近，却应进入操作与咨询两个分支。这解释了为什么向量方法需要边界反例，也解释了通用语义相似度不能直接等同于业务决策。</p>
<p>示例级检索和类别中心各有代价。类别中心把同类向量平均后比较，存储与检索更简单，却可能丢失同类样例中不同表达的特征；示例级检索保留多样性，但更容易被单条错误标注或偶然相似的例子影响。本文实际运行的是“每类最高示例相似度”，类别中心方案未参与本次比较。</p>
<p>模型输入长度也是限制。把长历史直接拼接到句向量编码器，可能截断关键上下文；本实验只处理短句，不据此推断多轮能力。</p>
<h2>六、选择拒识阈值</h2>
<p>每个方法分别使用验证集搜索最高分阈值和前两名分差。教学目标函数为“5 倍错误接收 + 1 倍已知输入未选择”，优先减少错误接收，但接受一定的拒识代价。</p>
<p>规则命中、分类概率和余弦相似度使用同一候选网格只是便于演示，最终各自选择参数。阈值不是通用常数，标签、示例和模型变化后都需要重新检查。</p>
<p>更完整的解释见<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification">未知意图、拒识与澄清</a>。拒识表示当前证据不足以选择，不等于已经确定用户在业务范围外。</p>
<h2>七、分类结果与错误分析</h2>
<table>
<thead>
<tr>
<th>方法</th>
<th>已知输入原始 Top-1</th>
<th>接收数 / 21</th>
<th>接收错误数</th>
<th>范围外误接收 / 7</th>
</tr>
</thead>
<tbody><tr>
<td>关键词规则</td>
<td>8/14</td>
<td>10/21</td>
<td>3</td>
<td>1/7</td>
</tr>
<tr>
<td>TF-IDF + 逻辑回归</td>
<td>9/14</td>
<td>6/21</td>
<td>1</td>
<td>1/7</td>
</tr>
<tr>
<td>句向量近邻</td>
<td>10/14</td>
<td>4/21</td>
<td>0</td>
<td>0/7</td>
</tr>
</tbody></table>
<p>原始 Top-1 只看 14 条已知输入，不包含范围外样本，也不使用拒识策略。它有助于分离“候选排序能力”和“是否接收的决策”。规则零命中时的原始排序会按标签顺序打破平局，这一结果仅用作不含拒识策略的分类参照，实际策略不会接收零命中或并列最高分。</p>
<p>阈值后的接收数以全部 21 条 probe 输入为分母；接收错误包含已知类误判和范围外误接收。因此要同时观察排序与拒识：前者回答“最像哪个类别”，后者回答“现在是否足以作出选择”。</p>
<p>下面三句分别对应否定表达、已有订单修改和范围外请求中的识别错误：</p>
<table>
<thead>
<tr>
<th>输入</th>
<th>正确类别</th>
<th>规则决策</th>
<th>TF-IDF 决策</th>
<th>句向量决策</th>
</tr>
</thead>
<tbody><tr>
<td>别取消了，把 DEMO-Z 的订单详情给我看看</td>
<td>查订单</td>
<td>取消预订</td>
<td>需要区分候选</td>
<td>拒识</td>
</tr>
<tr>
<td>DEMO-Z 这张订单换个房型</td>
<td>修改预订</td>
<td>查房</td>
<td>需要区分候选</td>
<td>拒识</td>
</tr>
<tr>
<td>明天的足球比赛几点开始</td>
<td>范围外</td>
<td>政策咨询</td>
<td>政策咨询</td>
<td>拒识</td>
</tr>
</tbody></table>
<p>第一句需要理解“别”否定的是取消；第二句里的“换房型”发生在已有订单上，应进入修改流程；第三句虽然包含“几点”，却谈论足球比赛。它们说明：关键词、局部字符和语义相似度各自提供一部分证据，业务目标还取决于否定范围、任务阶段和能力目录。</p>
<p>除了总分，还应分析具体错例。如果取消政策被识别为取消动作，要检查标签正反例和语义边界；如果开票被识别为订单查询，要检查范围外样本；如果换一种说法就识别失败，应检查文本特征和训练样本是否覆盖这类表达。</p>
<h2>八、推理耗时对比</h2>
<table>
<thead>
<tr>
<th>方法</th>
<th>单条推理中位数</th>
<th>本次样本 P95</th>
</tr>
</thead>
<tbody><tr>
<td>关键词规则</td>
<td>0.005 ms</td>
<td>0.005 ms</td>
</tr>
<tr>
<td>TF-IDF + 逻辑回归</td>
<td>0.100 ms</td>
<td>0.203 ms</td>
</tr>
<tr>
<td>句向量近邻</td>
<td>4.542 ms</td>
<td>4.936 ms</td>
</tr>
</tbody></table>
<p>计时采用 CPU 预热后逐条推理，排除模型加载；完整环境在结果文件中。规则只做模式匹配，TF-IDF 需要稀疏特征与线性运算，句向量还要执行神经网络编码，这些计算路径解释了延迟差异。</p>
<p>线上成本还取决于调用方式。固定示例的向量可以预先计算；多个输入可批量编码；高频、确定的规则入口可以减少模型调用。优化之前先区分初始化、单次推理和排队时间，才能找到真正的瓶颈。</p>
<h2>九、根据业务需求选择方法</h2>
<p>可以根据任务约束安排实验，而不是先决定必须使用某类模型。</p>
<p>固定菜单、少量明确命令可以先用规则。标签稳定且已有标注时，TF-IDF 加线性分类器能提供成本较低的比较起点。希望通过增加例句快速覆盖同义表达时，可以尝试向量近邻，但要专门检查否定与范围外输入。需要复杂上下文解释时，再比较大模型或适合的专用模型。</p>
<p>希望进一步理解细粒度意图分类，可以阅读 BANKING77 相关研究，了解如何通过双编码器表示例句与区分相近类别。<a href="https://arxiv.org/abs/2003.04807">BANKING77 论文</a></p>
<p>小模型微调、蒸馏和线上分层路由需要更多数据与实验，本篇先建立三个可运行基线。采用更复杂的方法前，应先明确要解决哪些已发现的问题，再用实验验证效果。</p>
<h2>十、运行实验代码</h2>
<p>在下载包根目录创建隔离环境：</p>
<pre><code class="language-bash">python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r batch2/requirements.txt
python batch2/run_experiment.py
python batch2/tests.py</code></pre><p>首次运行句向量方案需要从模型仓库下载权重。模型 ID 与 revision 保存在 <code>batch2/model.json</code>，库版本与原始结果在 <code>batch2/results/experiment.json</code>。下载包只包含代码、数据、配置与实验结果，不重新分发模型权重。</p>
<p>只运行规则与 TF-IDF，可使用 <code>python batch2/run_experiment.py --without-embeddings</code>；当前完整 requirements 仍安装实验依赖，读者可以在确认依赖后另建最小环境。离线策略与任务图测试只用标准库。</p>
<p>保留基线的标签定义、训练数据、分数、拒识结果和错例，便于比较后续改动。补充数据或更换模型后，可以据此检查哪些错误得到修复，哪些类别的表现有所下降。</p>
<p>继续阅读：<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design">标签设计</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/llm-intent-recognition-structured-output">大模型结构化识别</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification">未知意图与澄清</a>。</p>
<h2>进阶阅读</h2>
<ul>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation">意图识别怎么评测：从分类准确率到多轮任务成功率</a>。</li>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning">意图识别小模型实战：分类头训练、知识蒸馏与低成本部署</a>。</li>
</ul>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-rules-vectors-baselines#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184494957866782720</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  <item>
    <title>意图标签怎么设计：分类粒度、槽位边界与多级意图体系</title>
    <link>https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design</link>
    <pubDate>Wed, 23 Sep 2026 02:36:59 GMT</pubDate>
    <description>设计意图识别系统时，应先明确标签定义，再选择模型。假如“取消收费吗”和“帮我取消”都被标成“取消订单</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design'>https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design</a></blockquote>
          <p>设计意图识别系统时，应先明确标签定义，再选择模型。假如“取消收费吗”和“帮我取消”都被标成“取消订单”，模型把它们分到一起完全符合训练数据，业务却可能因此误操作。</p>
<p>这篇文章从酒店预订与售后场景出发，给出一套可复用的标签定义方法：如何选择粒度，如何划分意图与槽位，何时需要层级或多标签，以及业务变化后如何维护版本。配套文件包含 7 个标签的定义、正例、反例、允许槽位和能力边界。</p>
<p><a href="https://liuyaowen.cn/api/v3/objects/file/ojwiqkt27icgck62v6.zip">下载标签规范、基线代码与任务依赖示例</a>。系列总览见<a href="https://liuyaowen.cn/posts/ai-machine-learning/202503211">大模型对话系统中的意图识别</a>。</p>
<h2>一、根据业务流程划分意图</h2>
<p>划分意图时，可以比较两种请求的处理流程、参数要求、权限和完成标准。差异明显时，通常应分成不同标签；如果只是城市或日期不同，则可以用同一意图下的槽位表示。</p>
<p>例如：</p>
<table>
<thead>
<tr>
<th>表达</th>
<th>系统需要做什么</th>
<th>标签</th>
</tr>
</thead>
<tbody><tr>
<td>取消预订会收手续费吗</td>
<td>查询规则并解释</td>
<td><code>policy_query</code></td>
</tr>
<tr>
<td>帮我取消订单 DEMO-A</td>
<td>核验订单与条件，进入取消流程</td>
<td><code>cancel_booking</code></td>
</tr>
<tr>
<td>取消后退款多久到账</td>
<td>查询退款时效或流程</td>
<td><code>refund_query</code></td>
</tr>
<tr>
<td>不要取消，只查一下订单</td>
<td>查询已有订单</td>
<td><code>order_query</code></td>
</tr>
</tbody></table>
<p>不能简单按主题词划分。上面四句都可能含有“取消”，但查询规则、执行取消、退款咨询与订单查询承担不同职责。</p>
<p>也不应把每个 API 都变成一个用户意图。用户想“查订单状态”，后端可能先查订单、再查支付、再查履约；这些是实现步骤。如果直接用内部 API 名称作为标签，后端接口调整时，标签体系也需要跟着修改。</p>
<h2>二、区分意图与槽位</h2>
<p>“订北京酒店”和“订上海酒店”通常共享 <code>book_room</code>，城市放进槽位。如果把每座城市都建成一个标签，新城市上线时就必须增加类别、重标数据和重新评估模型。</p>
<p>槽位不仅是从文字中找到实体，还要确定实体在任务中的角色。“从北京出发去上海”有两个城市，但只有任务定义才能决定哪个是出发地、哪个是目的地。酒店例子则需要明确是在查询哪座城市的房源。</p>
<p>一个标签可以写成：</p>
<pre><code class="language-json">{
  "id": "book_room",
  "version": "1.0",
  "path": "transaction/booking",
  "definition": "建立新的酒店预订目标",
  "includes": ["我要订房", "预订尚未提交，入住改成后天"],
  "excludes": ["现有订单改期", "只想看看房价"],
  "allowed_slots": ["city", "hotel", "check_in", "check_out", "room_type"],
  "write_capability": true
}</code></pre><p>这里把“预订尚未提交，改入住日”放在预订里。只有修改已有订单时，才进入 <code>change_booking</code>。因此标签不能只由当前句子决定，还需要知道任务处于哪个阶段。</p>
<p>附件中的 <code>teaching_required_slots</code> 仅用于教学状态机计算缺失参数。真实预订还可能需要具体产品、入住人、价格、支付方式等信息；示例中的必填字段齐全，只表示可以进入后续业务校验。</p>
<h2>三、选择合适的分类粒度</h2>
<p>过粗的标签把不同业务分支混在一起，后续仍需第二次识别。比如“售后”可能包含取消、改期、退款咨询与投诉，如果下游不知道应走哪个流程，这个分类就没有提供足够信息。</p>
<p>标签划分过细时，相近类别可能难以区分，标注一致性也更难保证。“询问早餐时间”和“询问退房时间”如果都由同一个政策检索模块处理，可以先共享 <code>policy_query</code>，把主题作为 <code>topic</code>。若后续两类能力有不同数据、合规要求或业务指标，再考虑拆分。</p>
<p>判断是否拆分类别时，可以检查：</p>
<ul>
<li>是否存在稳定、可解释的用户目标差异。</li>
<li>下游能否据此减少歧义或选择不同能力。</li>
<li>标注者能否根据对话证据一致地区分。</li>
<li>每类是否有足够样本，新增类别的维护收益是否明确。</li>
</ul>
<p>“模型分不开”并不自动说明应该合并，也可能是数据不足。相反，“模型能分开”也不自动说明有必要拆成业务标签。先确定处理需求，再检查模型能力。</p>
<h2>四、编写标签定义</h2>
<p>除了标签名称，还需要说明适用范围、反例和冲突处理方式：</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>需要写清楚的内容</th>
</tr>
</thead>
<tbody><tr>
<td>稳定 ID</td>
<td>代码与历史数据使用的标识，不随展示名称变化</td>
</tr>
<tr>
<td>定义</td>
<td>用户要达到的目标</td>
</tr>
<tr>
<td>正例</td>
<td>多种自然表达和必要的上下文</td>
</tr>
<tr>
<td>排除项</td>
<td>最容易误入的相似请求</td>
</tr>
<tr>
<td>允许槽位</td>
<td>该任务可以接受哪些参数</td>
</tr>
<tr>
<td>阶段约束</td>
<td>是否要求已有订单、是否允许继续未完成任务</td>
</tr>
<tr>
<td>能力边界</td>
<td>只查询、允许变更，还是系统暂不支持</td>
</tr>
<tr>
<td>不确定处理</td>
<td>证据不足时补什么信息、何时澄清</td>
</tr>
<tr>
<td>版本</td>
<td>定义何时生效，哪些旧数据需要复核</td>
</tr>
</tbody></table>
<p>正反例最好成对出现：“取消 DEMO-A”与“先别取消 DEMO-A”，“查房价”与“按这个价格预订”。只提供典型正例，无法说明相似请求应该如何区分。</p>
<p>不要把不存在于输入或上下文中的事实写进答案。如果数据标注必须依靠标注者猜测，应该改成澄清样例，或补充真实可用的上下文。</p>
<h2>五、酒店业务标签示例</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>目标</th>
<th>重点排除</th>
</tr>
</thead>
<tbody><tr>
<td><code>policy_query</code></td>
<td>咨询设施、入住与取消规则</td>
<td>实际操作、退款专门流程</td>
</tr>
<tr>
<td><code>room_search</code></td>
<td>查询尚未购买的房间、房型和报价</td>
<td>提交预订、查询已有订单</td>
</tr>
<tr>
<td><code>book_room</code></td>
<td>建立新预订及编辑提交前参数</td>
<td>修改已有订单</td>
</tr>
<tr>
<td><code>order_query</code></td>
<td>查询已有订单信息或状态</td>
<td>修改订单、退款专门进度</td>
</tr>
<tr>
<td><code>change_booking</code></td>
<td>修改已有预订</td>
<td>新建另一笔预订</td>
</tr>
<tr>
<td><code>cancel_booking</code></td>
<td>请求取消已有预订</td>
<td>咨询规则、被否定的取消</td>
</tr>
<tr>
<td><code>refund_query</code></td>
<td>咨询退款流程、金额和到账</td>
<td>直接付款或发起退款</td>
</tr>
</tbody></table>
<p>这里对退款咨询单独分类，是示例的业务设计选择。另一个系统可能把它归入订单查询的子类。只要定义一致、处理路径合理，两种设计都可以成立；不应把本表当作所有酒店系统的标准答案。</p>
<h2>六、层级分类与多标签分类</h2>
<p>层级用于组织从粗到细的概念。例如 <code>information/order</code>、<code>information/policy</code>、<code>transaction/cancellation</code>。它可以帮助导航、分层分析错误和管理团队职责。</p>
<p>但是否采用两阶段分类需要实测。先把请求错分为 information，后续只在信息类里选叶子标签，会将第一次错误传递下去。小规模标签目录可以直接预测叶子 ID，层级只作为管理结构。</p>
<p>多标签则表示同一输入提出多个目标：“查订单，并告诉我早餐时间”。它不是层级更深，而是同时存在两项任务。</p>
<p>还有一种情况是同类多任务：“北京订一间，上海再订一间。”如果用集合保存 <code>{book_room}</code>，任务数量就丢失了。应为任务分配独立 ID，再分别保存参数。具体结构见<a href="https://liuyaowen.cn/posts/ai-machine-learning/multi-intent-recognition-task-dependencies">多标签、条件与任务依赖</a>。</p>
<h2>七、区分未知意图、歧义与参数缺失</h2>
<p>“我要订房”目标明确但缺城市与日期，应继续补槽。“把那个取消”在有两笔订单时是指代歧义，应询问目标。“开一张发票”在没有开票能力的系统中，则是范围外请求。</p>
<p>这三种情况应使用不同的状态字段表示。如果全部归为 unknown，下游程序就无法判断应该补充参数、澄清对象，还是说明当前不支持该功能。</p>
<p>还需要区分语义范围与临时可用性。系统支持查订单，只是订单服务此刻超时，意图依然是 <code>order_query</code>；这是服务失败，不该被重新标成范围外。反之，系统从来没有开票能力，即使模型能识别开票需求，也应返回当前不支持该功能。</p>
<p>对于“以后叫我大英雄”，综合助手可以交给偏好模块；酒店业务识别器可以报告当前范围不支持。能力目录不同，合理标注也会不同。</p>
<h2>八、标签变更与数据迁移</h2>
<p>设想后来上线了发票功能。旧数据中的“开住宿发票”原来是范围外，新版本要改成 <code>invoice_request</code>。正确做法是创建新标签版本，列出受影响样本，并在新能力已经可用后切换路由。</p>
<p>如果将一个粗标签拆成两个细标签，旧标签往往无法自动映射到唯一的新标签。需要回看原文和上下文，不能只做字符串替换。合并标签虽然容易映射，也可能改变历史指标的分母与业务意义。</p>
<p>可采用以下迁移步骤：先建立新定义与差异说明，抽样复标；再以新旧定义分别回放固定数据，检查路由变化；随后更新标签、模型、Prompt、数据与接口版本；保留回滚所需的旧版本。历史报表使用哪个标签版本也要记录。</p>
<h2>九、统一代码中的标签定义</h2>
<p>附件 <code>batch2/data/taxonomy.json</code> 保存完整目录，叶子 ID 与第一批 <code>core.py</code> 保持一致。可以在工程根目录检查：</p>
<pre><code class="language-python">import json
from pathlib import Path
from core import LABELS, SLOTS

catalog = json.loads(Path("batch2/data/taxonomy.json").read_text())
assert {item["id"] for item in catalog["labels"]} == set(LABELS)
for item in catalog["labels"]:
    assert set(item["allowed_slots"]) == set(SLOTS[item["id"]])
print("标签 ID 与允许槽位一致")</code></pre><p>将标签规范、模型输入和服务校验共用同一份目录，可以避免不同模块使用不一致的定义。比如新增了一个槽位，更新规范之后应同步生成 Prompt 和校验规则，而不是分别维护三份字符串。</p>
<p>标签定义应同时用于数据标注、模型识别和业务处理。把容易混淆的请求整理成正反例，再据此补充语料和比较模型，才能判断改进是否有效。</p>
<p>继续阅读：<a href="https://liuyaowen.cn/posts/ai-machine-learning/complex-intent-recognition-corpus">复杂意图语料</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-rules-vectors-baselines">三种基础识别方法实测</a>、<a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-unknown-clarification">未知意图与澄清策略</a>。</p>
<h2>进阶阅读</h2>
<ul>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/intent-recognition-evaluation">意图识别怎么评测：从分类准确率到多轮任务成功率</a>。</li>
<li><a href="https://liuyaowen.cn/posts/ai-machine-learning/small-model-intent-recognition-finetuning">意图识别小模型实战：分类头训练、知识蒸馏与低成本部署</a>。</li>
</ul>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/ai-machine-learning/intent-label-taxonomy-design#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">184494930536697856</guid>
  <category>post</category>
<category>AI 与机器学习</category>
 </item>
  
</channel>
</rss>