一张工单要选一个三级分类:一级是「系统」,二级是功能域,三级是具体问题。树有五十多个一级、几百个叶子。

这件事前后换了四种做法。这篇讲后三种——从「让模型背菜单」,到「让模型查树」,再到「直接把整棵子树给它」——以及每一次换的依据。

起点:一份真实反馈统计

先看数据。1158 条真实的纠偏反馈(只统计真正跑过 AI 的样本):

一级(系统)准确率完整三级路径准确率
AI(把菜单塞进 prompt 的两跳)71.9%49.7%
关键词引擎46.6%2.2%

两条结论决定了后面的一切:

  1. 关键词引擎的三级准确率约等于零。 它一级能对近一半,三级几乎从不对。所以「AI 选系统 → 引擎在子树里挑三级」这个分工是错的,三级也必须交给模型。
  2. AI 的错例里,大部分是「一级对了、二三级错了」。 主战场在二三级。而这些错不是召回问题,是语义区分问题——两个选项字面上毫无关系,要看懂业务才分得开。

(关键词引擎为什么在三级上一败涂地,是另一篇的内容。)

第一种:把菜单塞进 prompt

最初的 AI 链路是两跳:

  • 跳 1:五十多个一级系统名一次性塞进 system prompt,让模型选一个;
  • 跳 2:引擎算好 Top-12 候选,让模型回一个编号。

问题在第一跳。代码里有一条兜底日志:「丢弃清单外的系统名」——这条日志之所以存在,就是因为模型确实会编出树上没有的系统名。

把清单塞进 prompt 让模型「对着菜单认」,本质是让它背。背就会背错、会编。

第二种:给它三个检索 tool

改成让模型查树,而不是背树。三个只读 tool:

listSystems() → 一级系统及各自的叶子数
search(keywords[]) → 跨全树模糊检索,主力
browse(system, level2?) → 展开一棵子树的全貌

本质的差别是:模型拿到的是检索结果,不是记忆产物。 它带着关键词去查,看到真实候选再决策——返回的每一条路径都直接取自树节点,它编不出不存在的路径。

而且检索能治错别字:用户描述里经常把分类名写错一两个字,模糊检索能召回,「对着菜单认」不能。

全部是 read 类,符合「模型只能自主调只读 tool」的既有边界(见 tool 的副作用分级),不需要为它放开任何新的口子。

下面这份实现用的是一棵假数据树(「借阅系统」有三个版本,还有采购、预算、巡检几个业务系统),但结构和真树的难点一样:

src/category/category-search.ts
/**
* 让模型「查」分类树,而不是「背」它:三个只读 tool 的检索核心。
* listSystems() → 一级系统及各自叶子数
* search(keywords[]) → 跨全树模糊检索,同名二三级跨系统折叠
* browse(system, level2?) → 展开子树全貌
* 返回的每一条路径都直接取自树节点——模型编不出树上不存在的路径。
*/
export interface CategoryNode { title: string; children?: CategoryNode[] }
export interface LeafPath { titles: string[] } // [一级, 二级, 三级]
export function flattenLeaves(tree: CategoryNode[], prefix: string[] = []): LeafPath[] {
const out: LeafPath[] = [];
for (const n of tree) {
const titles = [...prefix, n.title];
if (n.children?.length) out.push(...flattenLeaves(n.children, titles));
else out.push({ titles });
}
return out;
}
/* ---------- 模糊匹配:2-gram + 完整包含,容忍错别字 ---------- */
export const normText = (s: string): string =>
s.toLowerCase().replace(/[\s·//()(),,、.]+/g, '');
export function bigrams(s: string): Set<string> {
const t = normText(s);
const out = new Set<string>();
for (let i = 0; i < t.length - 1; i++) out.add(t.slice(i, i + 2));
return out;
}
/**
* 标题 vs 检索词的相关度。
* - 任一方完整包含另一方:强命中(full)
* - 否则按 2-gram 重合比例给分 —— 错一个字仍能召回
*/
export function softMatch(title: string, kw: string): { s: number; full: boolean } {
const t = normText(title);
const k = normText(kw);
if (!t || !k) return { s: 0, full: false };
if (t.includes(k) || k.includes(t)) {
return { s: 3 + Math.min(k.length, t.length) * 0.25, full: true };
}
const a = bigrams(t);
const b = bigrams(k);
if (!a.size || !b.size) return { s: 0, full: false };
let common = 0;
for (const g of b) if (a.has(g)) common++;
const ratio = common / Math.min(a.size, b.size);
if (ratio >= 0.5) return { s: 3 * ratio, full: false };
// 第二档:字符覆盖率。短词里错一个字会毁掉大半 bigram
// (「巡简任务」vs「巡检任务」只剩「任务」一个 bigram 相同),但字符仍有 3/4 重合。
// 只对 ≥3 字的检索词启用,免得两个字的泛词到处命中。
if (k.length >= 3) {
const chars = new Set(t);
let hit = 0;
for (const c of k) if (chars.has(c)) hit++;
const cov = hit / k.length;
if (cov >= 0.75) return { s: 2 * cov, full: false };
}
return { s: 0, full: false };
}
/* ---------- search ---------- */
export interface CategoryHit {
/** 同名跨系统时是「二级 / 三级」;只属于一个系统时是完整路径 */
path: string;
systems: string[];
/** 各系统对应的完整路径(直填用) */
pathsBySystem: Record<string, string[]>;
score: number;
why: string;
/** 跨系统同名时,告诉模型怎么消歧 */
note?: string;
}
/** 同一类产品的不同版本:它们之间的同名分类,可以靠「客户用的是哪个版本」这个事实消歧 */
export const isVersioned = (sys: string): boolean => sys.startsWith('借阅系统');
function disambiguationNote(systems: string[]): string | undefined {
if (systems.length < 2) return undefined;
return systems.every(isVersioned)
? `该分类在 ${systems.length} 个版本下同名;请按客户实际使用的版本选择。`
: `该分类同时存在于 ${systems.join('、')};这几个系统业务不同,请按问题内容判断。`;
}
export function search(tree: CategoryNode[], keywords: string[], limit = 12): CategoryHit[] {
const kws = keywords.map(normText).filter((k) => k.length >= 2);
if (!kws.length) return [];
const groups = new Map<string, {
score: number;
reasons: Set<string>;
pathsBySystem: Record<string, string[]>;
}>();
for (const leaf of flattenLeaves(tree)) {
let total = 0;
const reasons: string[] = [];
for (const kw of kws) {
// ★ 同一个关键词在一条路径里只计最高的一次。
// 逐级累加会让「借还」在二级『借还管理』和三级『自助借还机接口』各拿一次分,
// 整棵子树白得同样的底分,真正的区分词反而被淹没。
let best = 0;
let bestWhy = '';
leaf.titles.forEach((title, i) => {
const isLeaf = i === leaf.titles.length - 1;
const weight = isLeaf ? 1.6 : i === 0 ? 0.8 : 1.2; // 三级最重
const hit = softMatch(title, kw);
const pts = hit.s * weight;
if (pts > best) {
best = pts;
bestWhy = hit.full ? `『${title}』与「${kw}」完整重合` : '';
}
});
if (best > 0) {
total += best;
if (bestWhy) reasons.push(bestWhy);
}
}
if (total <= 0) continue;
const [system, ...sub] = leaf.titles;
const key = sub.join(' / ');
const g = groups.get(key) ?? { score: 0, reasons: new Set<string>(), pathsBySystem: {} };
// ★ 同名组取最高分而不是累加:「在越多系统下同名」不代表越相关
g.score = Math.max(g.score, total);
reasons.forEach((r) => g.reasons.add(r));
g.pathsBySystem[system!] = leaf.titles;
groups.set(key, g);
}
return [...groups.entries()]
.map(([key, g]) => {
const systems = Object.keys(g.pathsBySystem);
return {
path: systems.length === 1 ? `${systems[0]} / ${key}` : key,
systems,
pathsBySystem: g.pathsBySystem,
score: Math.round(g.score * 10) / 10,
why: [...g.reasons].slice(0, 2).join(';') || '模糊相关',
note: disambiguationNote(systems),
};
})
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/* ---------- listSystems / browse ---------- */
export function listSystems(tree: CategoryNode[]): Array<{ system: string; leaves: number }> {
return tree.map((n) => ({ system: n.title, leaves: flattenLeaves([n]).length }));
}
export function browse(tree: CategoryNode[], system: string, level2?: string, limit = 120) {
const root = tree.find((n) => n.title === system)
?? tree.find((n) => softMatch(n.title, system).s > 0); // 容忍写法差异
if (!root) {
return { ok: false as const, error: `没有叫「${system}」的系统;先调 listSystems() 看看有哪些` };
}
let paths = flattenLeaves([root]).map((l) => l.titles);
if (level2) paths = paths.filter((p) => p[1] === level2);
return {
ok: true as const,
system: root.title,
paths: paths.slice(0, limit).map((p) => p.join(' / ')),
truncated: paths.length > limit,
};
}

在这棵假树上跑的 15 项测试里,最能说明问题的几条:

✓ 版本同名折叠成一条 用户管理 / 密码重置与找回 <- 标准版|高校版|云版
✓ 带区分词的排第一 接口服务相关 / 自助借还机接口(11.2) | 借还管理 / 借书失败(4.2)
✓ 错别字容忍 「巡简任务」仍召回「巡检任务」
✓ 对照组:逐级累加时「借还管理」下三个叶子全部并列 1,1,1

三个设计点,全是实测改出来的

① 同名叶子要跨系统折叠。

真树里有大量完全同名的二三级路径。第一版每个叶子返回一条完整路径,结果检索一次:

版本 A / 用户管理 / 密码重置与找回 10.4
版本 B / 用户管理 / 密码重置与找回 10.4 ← 完全同名
版本 C / 用户管理 / 密码重置与找回 10.4 ← 完全同名
…再三条

12 个名额里 6 条是同一个叶子,分数一模一样(区别只在一级系统名,而检索词里没有系统名)。模型在六条看不出差别的路径之间只能靠猜。

改成按「二级 / 三级」折叠:同名的合成一条,把「哪些系统有它」列在 systems 里。名额腾出来装真正不同的候选。

② 同一个关键词在一条路径里只计一次。

第一版逐级累加:「借还」在二级『借还管理』和三级『自助借还机接口』各拿一次分。于是那棵子树下每个叶子都白得同样的底分——测试里那条对照组,三个叶子完全并列。真正的区分词(「自助」)一分未得。

这个坑在项目另一处的注释里早就记载过,原话是「逐 token 计分天然偏向同一个词在多级重复出现的候选,而那恰恰是冗余、不是信号」。我读过那段注释,然后在一个新函数里换个写法重犯了一遍。

读过的教训不等于学会的教训。同类逻辑应该复用同一个函数,而不是在新地方凭记忆重写一遍。

③ 同名有两种,消歧手段完全不同。

  • 同一个产品的不同版本同名(如借阅系统的三个版本):客户用的是哪个版本,是查表能确定的事实——提示模型按事实选;
  • 不同业务系统碰巧同名(如采购和预算都有「账号权限问题」):查表无能为力,只能靠问题内容判断。

第一版注释把折叠的理由写成了「版本由查表确定」——那会让后来人以为接上查表就万事大吉,而真树里有四十多组跨业务的同名,永远不会被查表解决。

它准了,但它慢

tool 链的准确率明显上去了。代价是来回 2 到 4 次:模型 search 一次,结果不满意,换词再 search,还不行就 browse 一棵子树。

而且有一次实测很说明问题:面对五个版本的同名分类,模型挑了其中一个——它自己写的理由是「按本门店实际使用的版本选择」。它知道自己没有依据,但还是选了。

第三种:量一下,整棵子树塞得下

这时候有人提了一个很朴素的问题:

与其每次推 12 条给模型选、不满意再重复查,为什么不直接把对应系统下的所有分类都交给它?

量了一下体积:

体积
全局目录(所有系统 + 全部二级功能域)约 1.9k 字符
绝大多数系统的完整子树≤ 2.6k 字符
最大的那一棵约 6.2k 字符

全塞得下。 那个 Top-12 的候选池限制,以及为了绕开它而生的多轮重查,本来就不必存在。

多轮检索本质上是在补「候选池太小」的窟窿。 候选池能装下全部答案的时候,这个窟窿在结构上就不存在了。

于是变成了固定的两跳:

跳 1 全局目录(系统 + 二级功能域) → { systems: [...], level2: [...], reason }
跳 2 选中系统的完整子树 → { path, confidence, reason }

tool 链当天就删了。

跳 1 为什么要带上二级功能域

旧的菜单里只有系统名。有一张真实的工单,描述大意是「想看本月每一类的数量统计」,旧链路填到了「订单 / 订单查询」,正确答案在「报表」下——而描述里压根没有「报表」两个字。

模型只有看见「报表」这个功能域存在,才推得出「看数量统计 = 看报表」。只给系统名,它无从推起。

同构系统在目录里也要折叠

几个版本的二级功能域一字不差,在目录里就是五行一模一样的内容。按「二级名串」分组折叠成一行——分组是数据驱动的,不写死名单,树改了自动适应。

值得说一句:折叠几乎不省体积(实测 2143 → 1900 字符)。它的价值是消除无意义的选择——模型不再面对五行一样的东西。收益在准确率,不在 token。

裁决权在代码,不在模型

同名组该怎么消歧,写成规则放进 prompt 行不行?

实测过:模型会把「版本待定」那条规则套用到跨业务的同名组上——本该自己按内容判断的事,它甩了回来说「待定」。

所以最后这件事写在代码里:

情形谁来定
系统唯一树本身就定了
同构组全是同一产品的版本 + 有「客户用哪个版本」的事实查表,不让模型猜
同构组全是版本 + 没有这个事实不填分类,提示人来选
同构组跨业务模型按问题内容判断

模型可以不遵守 prompt,但绕不过代码。 能用确定性规则判的,就别交给模型判。

一个看起来像「新方案不如旧方案」的 bug

两跳上线后,有一单的结果比旧链路差:客户说的是「某系统登不上」,排查发现是门店网络全断。新链路选了「网络相关」,旧链路选对了那个系统。

表面看是新方案退步了。实际是:新链路只接收了「问题描述」一个参数,而 AI 前面提炼描述时把重点写成了网络故障——那个系统名从头到尾没进过新链路的输入。让它选那个系统,它连那个系统存在都不知道。

旧链路反而拿得到完整对话,所以选对了。

是我接线时把一个参数丢了。

修法有一个值得记的细节:不要直接把整段对话塞进去。排查过程的篇幅常常远大于主诉本身——那段对话里九成在讲网线和指示灯,塞进去反而强化了错误信号。

所以拆成两个高信噪比的字段:

  • 关键词:AI 读完整段对话后提炼的,短,而且包含系统名;
  • 主诉:对话开头客户自己说的那几句。只取开头——质检的判据原话是「与客户主诉不对应,网络障碍仅为协助过程」:主诉在开头,协助过程在后面。

fail-open

任何一步失败(模型不可用、超时、树读不到、返回的路径对不上树)→ 返回 null,调用方退回旧链路。

分类填错可以改,卡住录单会堵死整条批量流程。


回头看这三次换方案,每次的依据都不是「新方法更先进」,而是一个具体的数字:

  • 背菜单 → 查树:因为有一条「丢弃清单外的系统名」的日志在不停地打;
  • 查树 → 两跳:因为量了一下,整棵子树只有两千多字符。

第二个数字其实一直都在,只是没人去量。在设计一个「怎么从大集合里挑一个」的方案之前,先确认那个集合是不是真的大。