一张工单要选一个三级分类:一级是「系统」,二级是功能域,三级是具体问题。树有五十多个一级、几百个叶子。
这件事前后换了四种做法。这篇讲后三种——从「让模型背菜单」,到「让模型查树」,再到「直接把整棵子树给它」——以及每一次换的依据。
起点:一份真实反馈统计
先看数据。1158 条真实的纠偏反馈(只统计真正跑过 AI 的样本):
| 一级(系统)准确率 | 完整三级路径准确率 | |
|---|---|---|
| AI(把菜单塞进 prompt 的两跳) | 71.9% | 49.7% |
| 关键词引擎 | 46.6% | 2.2% |
两条结论决定了后面的一切:
- 关键词引擎的三级准确率约等于零。 它一级能对近一半,三级几乎从不对。所以「AI 选系统 → 引擎在子树里挑三级」这个分工是错的,三级也必须交给模型。
- AI 的错例里,大部分是「一级对了、二三级错了」。 主战场在二三级。而这些错不是召回问题,是语义区分问题——两个选项字面上毫无关系,要看懂业务才分得开。
(关键词引擎为什么在三级上一败涂地,是另一篇的内容。)
第一种:把菜单塞进 prompt
最初的 AI 链路是两跳:
- 跳 1:五十多个一级系统名一次性塞进 system prompt,让模型选一个;
- 跳 2:引擎算好 Top-12 候选,让模型回一个编号。
问题在第一跳。代码里有一条兜底日志:「丢弃清单外的系统名」——这条日志之所以存在,就是因为模型确实会编出树上没有的系统名。
把清单塞进 prompt 让模型「对着菜单认」,本质是让它背。背就会背错、会编。
第二种:给它三个检索 tool
改成让模型查树,而不是背树。三个只读 tool:
listSystems() → 一级系统及各自的叶子数search(keywords[]) → 跨全树模糊检索,主力browse(system, level2?) → 展开一棵子树的全貌本质的差别是:模型拿到的是检索结果,不是记忆产物。 它带着关键词去查,看到真实候选再决策——返回的每一条路径都直接取自树节点,它编不出不存在的路径。
而且检索能治错别字:用户描述里经常把分类名写错一两个字,模糊检索能召回,「对着菜单认」不能。
全部是 read 类,符合「模型只能自主调只读 tool」的既有边界(见 tool 的副作用分级),不需要为它放开任何新的口子。
下面这份实现用的是一棵假数据树(「借阅系统」有三个版本,还有采购、预算、巡检几个业务系统),但结构和真树的难点一样:
/** * 让模型「查」分类树,而不是「背」它:三个只读 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,调用方退回旧链路。
分类填错可以改,卡住录单会堵死整条批量流程。
回头看这三次换方案,每次的依据都不是「新方法更先进」,而是一个具体的数字:
- 背菜单 → 查树:因为有一条「丢弃清单外的系统名」的日志在不停地打;
- 查树 → 两跳:因为量了一下,整棵子树只有两千多字符。
第二个数字其实一直都在,只是没人去量。在设计一个「怎么从大集合里挑一个」的方案之前,先确认那个集合是不是真的大。