alitrack

Hermes 的 385 条技能,索引怎么写才不被挑错

我给一个本地 agent 写了三份不同的技能索引,让它在同一批 77 个请求上跑。同一套提示词、同一批工具,只换索引写法。结果最扎眼的一条是:正确答案的加载错误率从 18.4% 掉到 4.1%,中间那段差距,跟模型聪不聪明基本无关。


● ● ●

先说这件事怎么来的

我自己的 agent(Hermes)里装了 385 个技能。每个技能在系统提示里占一行,形如 - 技能名: 一句话描述。它每轮对话都要先扫这 385 行,决定要不要加载某个技能、加载哪个。

这件事以前没人给出过可对照的数字,直到我读到 TypeSafe 官方放出的一份 cookbook:他们拿 Hermes 的技能名册当基准,量了两个指标——

  • 错误加载率
    :这轮确实该加载某个技能,但加载的不是那个对的;
  • 无谓加载率
    :这轮本来不该加载任何技能(日常闲聊、通用常识),却加载了。

官方在 182 条技能、488 个请求上给出的数是:错误加载 16.8% → 7.3%,无谓加载 9.8% → 4.0%。

我的名册是 385 条,是它的两倍多。于是我把这套装置原样搬过来,量了一遍我们自己的数。

● ● ●

装置:能复现的那部分先写清楚

  • 名册
    :385 条技能,58 个分类;描述平均 243 字符,最长 1026 字符。完整索引 105,549 字符。
  • 请求
    :49 条正向 + 28 条负向。正向请求是让模型读每个技能的正文摘录后,写出一句"用户真的会这么问"的话;负向请求是手写的日常杂事(报菜名、问天气、订票、写诗之类),先做过与技能名的词重叠检查,0 碰撞。
  • 被测 agent
    :本机 27B 模型,系统提示词逐字照抄 Hermes 的真实渲染逻辑,工具集一样(skill_view + 三个无关工具)。
  • 三个索引形态
    :把描述截断到 60 字符(官方文档说这是默认行为);完整描述(我们现在的做法);以及一个天花板组——直接把正确技能名塞进提示词。
  • 三组各 77 次调用,0 报错。

● ● ●

结果一:索引里写什么,比模型多想一层更管用

索引形态
索引体积
错误加载率
无谓加载率
截断到 60 字符
32,670 字符
18.4%
10.7%
完整描述
105,549 字符
14.3%
10.7%
直接把答案给它(天花板)
32,670 字符
4.1%
3.6%

天花板只有 4.1%,这说明两件事。一是这套装置真的有区分度,不是玄学;二是"选错技能"主要是索引表达问题——把正确答案摆进去,错误率立刻掉到四分之一。模型没换,提示词没换,只是它看到的信息变了。

● ● ●

结果二:我差点被自己的一组好数据骗了

既然"截断到 60 字符"会切断句子(我们中文描述平均 243 字符,60 字符的位置基本都在半句中间),那就别截断,让模型重写成 60 字符以内的完整句子。385 条全部重写成功,平均长度从 53.6 降到 37.4 字符,索引体积还小了 22%(32,670 → 25,356 字符)。

跑出来是这样:

索引形态
错误加载率
截断到 60 字符
18.4%
重写到 60 字符以内
12.2%
完整描述
14.3%

看起来赢麻了:比截断低 6.2 个百分点,连完整描述都超了,索引还只有它的四分之一。

然后我做了配对检验——同一批 49 个请求,逐条比对两种写法的对错。只有重写对的 4 条,只有截断对的 7 条,p = 0.549。

也就是说:这个差距完全在噪声里。三个索引形态两两之间全都不显著(p = 0.55 / 0.77 / 1.00)。真正显著的是它们和天花板之间的差距。

差 6 个百分点的两组数并排放在表里,看起来就像"结论",其实只差了 3 条请求的对错。n 不够的时候,表格比文字更容易骗人——因为它看起来像证据。

● ● ●

结果三:短索引的账,藏在另一个指标里

但我换了个更严的口径重新数了一遍:这一轮是不是加载过至少一个错的技能(而不是只看第一个)。

索引形态
首个加载错误
加载过错的技能
一轮加载多个技能
截断到 60 字符
18.4%
49.0%
23/49
重写到 60 字符以内
12.2%
49.0%
23/49
完整描述
14.3%
24.5%
11/49
天花板
4.1%
24.5%
12/49
四种索引形态在两个指标上的对照

四种索引形态在两个指标上的对照

但先别急着看第一列。我做过配对检验:只有改写对的 4 条、只有截断对的 7 条,p = 0.549——那 6 个百分点的差距在这个样本量下就是噪声。真正扛得住检验的是第二列:只有截断错的 14 条对只有完整错的 2 条(p = 0.004),只有改写错的 15 条对只有完整错的 3 条(p = 0.008)。更值得看的是最后一行——完整描述和"直接把答案给它"打平了(都是 24.5%,p = 1.000)。

这一列是 2 倍的差距,而且解释得通:描述不够用的时候,agent 会用"多加载"来对冲。它不确定,就把可能相关的两三个都拉进来——一轮加载多个技能的次数从 11/49 涨到 23/49。第一个挑对了,不代表没挑错,而每一个多加载的错误技能,都要真的吃一轮上下文。

所以"把描述压短"这件事,账不在"选对第一个"上,在"这一轮到底塞进来几个不该塞的"上。

● ● ●

结果四:描述越短,它越爱"多拉几个"

上面这些错误不是随机的,几乎全是近亲混淆——duckdb 被选成了 duckdb-quack,duckdb-ai-extension 被选成了 duckdb-community-extension。不是它没看懂描述,是同一族技能之间的边界没写清楚。

那我就顺着这条线索做了最后一组实验:把 385 条描述全部重写两遍。

  • 只压缩组
    :不给任何同族信息,只要求压到 106 字符左右
  • 压缩+写清分界组
    :把每个技能连同它的同族兄弟一起喂给模型,要求每条明确写出"什么时候用它、什么时候不要用它",长度控制在原文 ±30 字符内(实际落到 137 字符)

同一批 77 个请求,三组对照:

索引
平均描述长度
首个加载错
加载过错误技能
同族(近亲)错
原描述
243 字符
14.3%
24.5%6.1%
只压缩
106 字符
10.2%
36.7%
18.4%
压缩+写清分界
137 字符
12.2%
32.7%
8.2%

三句话:

  1. 01压缩直接推高了"近亲混淆",而这是整组实验里唯一达到显著水平的机制性差异
    :只压缩把同族错从 6.1% 抬到 18.4%(p = 0.031)。
  2. 02写清分界能把这笔账砍掉一半以上
    :18.4% → 8.2%(只有压缩组错的 5 条、只有分界组错的 0 条,p = 0.062),基本回到原描述的水平。
  3. 03但它救不回全部
    :整体看分界组(32.7%)仍不如原描述(24.5%)。所以最好的一档,依然是"别压缩"。

把五个索引形态按长度排开,两条线是同步单调的:

描述长度与错误加载的关系

描述长度与错误加载的关系

平均描述长度
索引形态
加载过错误技能
一轮多加载
243 字符
原描述
24.5%
11/49
137 字符
压缩+写清分界
32.7%
13/49
106 字符
只压缩
36.7%
16/49
54 字符
硬截断 60
49.0%
23/49
37 字符
改写 60
49.0%
23/49

所以机制是:描述写得越少,agent 越不确定,就越倾向于一次拉好几个进来(多加载 11 → 23/49)。"第一个就挑对"的概率反而上升了,但"这一轮到底塞进来几个不该塞的"同步变差。**我在结果二里被"首个加载"这个指标骗过一次,根因就在这儿——它在奖励广撒网。

再补一个我差点踩空的坑。顺着这个线索,我又单独试了"给原描述直接追加一句分界句(不压缩)"——第一次跑出来毫无改善,甚至更差。查下去才发现原因:模型写的分界句里,18% 提到的技能名是它自己造的简称("写扩展用 dev"、"通用可视化用 flint"),在索引里根本解析不到——等于没写。把名字规范化成索引里真实存在的技能名后重跑:

索引
首个加载错
加载过错误技能
原描述
14.3%
24.5%
加分界句(指针 18% 不可解析)
10.2%
28.6%
加分界句(指针已规范化)
6.1%22.4%

指针规范化之后,它在四项指标上都比原描述好,严指标甚至低于"直接把答案给它"(22.4% vs 24.5%)。但和原描述的差距仍未达显著(p = 0.125)——所以我只敢说"值得小范围试",不敢说"应该铺"。要验证它真的更好,得把正向样本从 49 条扩到 150 条以上。

这一条比结论本身更有用:分界句的成败不在文笔,在"名字能不能对上"。 我原本就打算把这套改写铺到 385 个真实技能文件上,如果没做这一步校验,会有将近五分之一的描述带着解析不到的分界句发出去——而它在指标上看起来跟"写得好"没什么区别。**

● ● ●

结果五:那道"要不要给建议"的闸门,两个开关是反的

官方那套方案里还有个闸门:先问三个问题,判断"这轮到底该不该给技能建议",三项取平均,低于阈值就闭嘴。我用 76 条能取到读数的请求(49 条该建议 / 27 条不该建议)逐项算了一下这个闸门有没有用:

那一问
AUC
通过率(该建议)
通过率(不该建议)
动作是否落在用户系统上
0.46
51%
44%
是否该照文档流程走
0.45
55%
48%
通用回答是否已经够用(取反)
0.74
98%
48%
闸门三项的 AUC

闸门三项的 AUC

AUC 低于 0.5 的意思是比抛硬币还差。前两问基本是噪声,甚至方向是反的——我用三项跑了个逻辑回归做留一验证,模型自己拟合出来的权重把前两项都翻成了负号。

有信息量的只有第三问。但把它调到最优阈值,仍然放行 48% 的无关请求:调闸门治不好,因为量本身太弱——我们这边做判断的是 4B 模型,官方的数是另一个量级的模型跑出来的。

这大概是我这轮最想记录下来的一句话:同一套判断方案,换一个模型来读,可能整体失效。方案没变,判据变了。

● ● ●

所以,该怎么写

如果只带走一条:别为了省索引体积把描述压短。

这条结论我一开始写反了。第一版草稿里我写的是"能写短就写短,别硬截"——因为改写组的首个加载错误率(12.2%)看着比截断组(18.4%)低。做配对检验才发现那 6 个百分点是噪声;换成"这一轮拉进来过几个错的"重新数,短索引才是完整描述的两倍,而且这个差距 p = 0.008。同一个实验,换个指标就从"没差别"变成"翻倍"——所以别只看第一列。

如果索引体积实在压不下去、必须写短,那就一定把"与同族兄弟的差异"留着:同样是把描述砍掉一大半,留下分界句的那组,近亲混淆是没留的那组的一半(8.2% vs 18.4%)。另外,分界句里提到的技能名必须是索引里真实存在的名字——写简称等于没写。功能罗列是最该先扔的部分,"什么时候不该用它"是最该留的部分。

补充四条边界,都是这轮实测里真实存在的:

  1. 01
    我这批样本是 77 条请求(49 条该建议 / 28 条不该建议),够看出「短索引 vs 完整描述」这种差距(p = 0.008),但不够分辨两三个百分点的细微差别——第一列那 6 个百分点就是反例。
  2. 02
    我的 agent 是 27B,官方那组数是另一个模型跑出来的,两组数不能直接比大小,能比的是同一套装置里的相对差距。
  3. 03
    上面这条"别压短"其实否掉了我自己原来的计划——我本来的落地动作是"把 385 条描述都压到 60 字符以内省索引体积"。数据不支持,撤回。
  4. 04
    官方文档说 Hermes 默认把描述截断到 60 字符,但我在本地这份检出里没找到这段逻辑(只有"分类太多时只保留技能名"的降级)。没在自己的代码里看到的事,就不写进结论。

回到最开始那个问题——385 条技能挤在一个索引里,agent 会挑错哪一条?多数时候不是它挑错了最像的那条,而是我们没告诉它"这些到底差在哪"。这个活儿模型替不了,得写索引的人自己干。

官方那份 cookbook 在 docs.typesafe.ai/cookbooks/skill_suggestion。

我这轮的量法比较笨,但胜在可复现:三份索引、77 个请求、原始记录都在本地。你更想看我先把哪块补上——扩样本把 6 个百分点的差距钉死,换更强的模型当那道闸门,还是把 385 条描述按家族边界全部重写一遍?

● ● ●

参考来源

  1. 01
    TypeSafe 官方 cookbook《skill_suggestion》——本文那套装置与两个指标的定义出处:docs.typesafe.ai/cookbooks/skill_suggestion
  2. 02
    本轮实验的原始记录(名册 385 条、49 条正向请求、28 条负向请求、三组索引形态各 77 次调用的逐轮结果、门控三项原始值)均在本机留档,未公开托管。
  3. 03
    门控三项(acts / procedure / prose)与 INVERTED 取反规则出自同一份官方 cookbook。