技能建议
技能建议
在一轮 agent 交互中,从 Nous Research 的 Hermes 目录的 182 个技能里最多挑出一个,用两次 TypeSafe 请求来排序并复查靠前的候选。
agent 通过截断并把所有技能都塞进系统消息来挑选技能,这会增加成本、拉低技能选择的表现, 并让会话后面的上下文腐化。我们用每轮两次 TypeSafe 请求来应对:一次给技能排序, 一次验证选中的那个,把错误的技能加载减少一半以上。
技能名册很大的 agent,几乎是在毫无信息的情况下做选择。名册是以索引的形式
送到它手上的:每个技能一行,描述被截断,免得全文
挤占对话。这里用的 agent harness Hermes 默认把它截到 60 个
字符。举例来说,在这个宽度下,编辑 .pptx 文件的技能
读起来和创作这类文件的技能几乎一样。让 agent 做一份路演 deck,它可能
加载错的那个。碰到没有任何技能合适的一轮,它可能还是会
加载一个,因为一串名字本身就在引诱它去猜。
这个 cookbook 不动那些描述,改用渐进式披露: 先把 182 个技能都廉价地读一遍,再详读其中三个。在「加载哪个技能(如果有的话)」 这个决定前面,放两次 TypeSafe 请求。第一次拿名册里的每个技能 和用户这一轮做比较排序,并判断这一轮到底需不需要技能。 第二次只重读前三名,这次带上每个技能的完整描述和它指令的 开头,并且允许把它们全部拒绝。
胜出的那个名字会变成 agent 那一轮系统提示词里多出来的一行:
<skill_relevance>
Relevant to the current request: pptx-author. Ignore this if it does not fit what the user
actually asked for.
</skill_relevance>
agent 保留完整的索引和自己的判断,那一行只是告诉它先去查哪个
条目。名册本身从不改变,所以任何针对它的前缀缓存依然
有效。在针对 claude-haiku-4-5-20251001 的 488 次请求中,使用 Hermes 名册里的技能:
| 加载错误的技能 | 没有合适的也加载一个 | |
|---|---|---|
| 单独的 agent,只有自己的名册 | 16.8% | 9.8% |
| 带 TypeSafe 建议的 agent | 7.3% | 4.0% |
| 直接拿到正确答案的 agent | 2.5% | 1.2% |
第三行说明,犯错的下限不是零:即使把正确的技能直接交给 agent, 它也不总会加载,再好的选择方法都绕不过这一点。
你最终会得到一个 suggest() 函数,最多返回一个技能名;一个
suggestion_block(),把它包起来放进系统提示词;以及生成上面那张表的
harness,可以随时指向你自己的名册。
flowchart LR
subgraph C1["Call 1 - skim all 182 skills"]
direction TB
Q1["<b>Choice:</b> which skill fits?<br/><i>all 182, one line each</i>"]
N1["<b>Nouls:</b> need a skill at all?<br/>· act on their stuff?<br/>· follow written steps?<br/>· or just talk?"]
%% invisible link: without an edge these two share a rank, which in a TB
%% subgraph puts them side by side instead of stacked
Q1 ~~~ N1
end
subgraph C2["Call 2 - read those 3 properly"]
direction TB
Q2["<b>Choice:</b> which of the 3?<br/><i>with real detail now</i>"]
N2["<b>Nouls:</b> does each one<br/>really do it?"]
Q2 ~~~ N2
end
REQ["the request"] --> C1
C1 -->|"top 3"| C2
C1 -->|"nothing<br/>applies"| STOP["suggest<br/>nothing"]
C2 -->|"none fit"| STOP
C2 -->|"a winner"| OUT["suggest<br/>the winner"]
环境准备
- 安装 TypeSafe 客户端、Anthropic 客户端,以及共享的 cookbook 辅助工具。
- 设置一个 TypeSafe API key,再为被测的 agent 设一个 Anthropic key。
pip install anthropic matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
export TYPESAFE_API_KEY=your-key-here
export ANTHROPIC_API_KEY=your-key-here
注意: 下面的代码块是同一个脚本,按顺序排列。想跟着做,就把它们按给出的顺序 放进一个文件里。
缓存结果
JsonCache 会把每次调用的结果按输入存起来,所以重新运行会重放
下面这些数字,而不是去调哪个 API。删掉 json_cache.json 就能跑实时版。
发布这次运行用的是 jev-1.12 和 claude-haiku-4-5-20251001,渲染于 2026-07-31。
import json
import os
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from time import perf_counter
import anthropic
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.ticker import PercentFormatter
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, Noul, TypeSafeClient
matplotlib.use("Agg") # headless render
TYPESAFE_MODEL = "jev-1.12"
AGENT_MODEL = (
"claude-haiku-4-5-20251001" # the agent under test, pinned so scores are stable
)
SHORTLIST = 3 # candidates carried from the first request into the second
EXCERPT_CHARS = (
700 # SKILL.md characters each candidate brings; the roster file stores 1600
)
GATE_THRESHOLD = (
0.30 # mean of the three request nouls, below which nothing is suggested
)
FITS_THRESHOLD = (
0.30 # a shortlist whose best "does this fit" noul is under this is dropped
)
WORKERS = 8 # small pool: enough to keep a live run to minutes, gentle on rate limits
assert EXCERPT_CHARS <= 1600, (
"the shipped roster file stores 1600 body characters per skill"
)
client = TypeSafeClient(
api_key=os.environ.get(
"TYPESAFE_API_KEY", "cache-only"
), # keyless kernels replay the cache
base_url=os.environ.get("TYPESAFE_ENDPOINT"),
timeout=120.0,
)
agent = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", "cache-only"))
json_cache = JsonCache(Path("json_cache.json"))
第 1 步:加载名册
hermes_roster.json 装着
NousResearch/hermes-agent(MIT)在一个
固定提交上的 182 个技能。每条记录包含技能的名字和分类、索引里展示的描述、
完整描述,以及它 SKILL.md 的开头。
下面的索引,以及提示词里它在上面那段指令,都是从 Hermes 原样抄来的。
ROSTER = json.loads(Path("hermes_roster.json").read_text(encoding="utf-8"))
BY_NAME = {skill["name"]: skill for skill in ROSTER}
# Verbatim from hermes-agent agent/prompt_builder.py:build_skills_system_prompt.
PREAMBLE = (
"## Skills (mandatory)\n"
"Before replying, scan the skills below. If a skill matches or is even partially relevant "
"to your task, you MUST load it with skill_view(name) and follow its instructions. "
"Err on the side of loading — it is always better to have context you don't need "
"than to miss critical steps, pitfalls, or established workflows. "
"Skills contain specialized knowledge — API endpoints, tool-specific commands, "
"and proven workflows that outperform general-purpose approaches. Load the skill "
"even if you think you could handle the task with basic tools like web_search or terminal. "
"Skills also encode the user's preferred approach, conventions, and quality standards "
"for tasks like code review, planning, and testing — load them even for tasks you "
"already know how to do, because the skill defines how it should be done here.\n"
"Whenever the user asks you to configure, set up, install, enable, disable, modify, "
"or troubleshoot Hermes Agent itself — its CLI, config, models, providers, tools, "
"skills, voice, gateway, plugins, or any feature — load the `hermes-agent` skill "
"first. It has the actual commands (e.g. `hermes config set …`, `hermes tools`, "
"`hermes setup`) so you don't have to guess or invent workarounds.\n"
"If a skill has issues, fix it with skill_manage(action='patch').\n"
"After difficult/iterative tasks, offer to save as a skill. "
"If a skill you loaded was missing steps, had wrong commands, or needed "
"pitfalls you discovered, update it before finishing.\n"
"\n"
)
FOOTER = "\n\nOnly proceed without loading a skill if genuinely none are relevant to the task."
IDENTITY = (
"You are Hermes, a capable AI assistant with access to tools and a library "
"of skills. You help the user with coding, research, and everyday tasks.\n\n"
)
def render_index() -> str:
"""The body of <available_skills>: skills grouped by category, both sorted by name."""
by_category = defaultdict(list)
for skill in ROSTER:
by_category[skill["category"]].append(skill)
lines = []
for category in sorted(by_category):
lines.append(f" {category}:")
for skill in sorted(by_category[category], key=lambda s: s["name"]):
lines.append(f" - {skill['name']}: {skill['description']}")
return "\n".join(lines)
CATALOG_PROMPT = (
IDENTITY
+ PREAMBLE
+ "<available_skills>\n"
+ render_index()
+ "\n</available_skills>"
+ FOOTER
)
widths = [len(skill["description"]) for skill in ROSTER]
print(f"{len(ROSTER)} skills in {len({s['category'] for s in ROSTER})} categories")
print(f"roster prompt: {len(CATALOG_PROMPT):,} characters")
print(
f"index description: {sum(widths) / len(widths):.0f} characters on average, "
f"{max(widths)} at most"
)
print("\none category, as the agent reads it:")
index_lines = render_index().splitlines()
start = index_lines.index(" apple:")
end = next(
i
for i in range(start + 1, len(index_lines))
if not index_lines[i].startswith(" ")
)
print("\n".join(index_lines[start:end]))
182 skills in 33 categories
roster prompt: 16,089 characters
index description: 54 characters on average, 60 at most
one category, as the agent reads it:
apple:
- apple-notes: Manage Apple Notes via memo CLI: create, search, edit.
- apple-reminders: Apple Reminders via remindctl: add, list, complete.
- findmy: Track Apple devices/AirTags via FindMy.app on macOS.
- imessage: Send and receive iMessages/SMS via the imsg CLI on macOS.
第 2 步:先单独给 agent 打分
requests.json 里有 488 条单轮请求,其中 315 条正好由一个技能覆盖,
另外 173 条没有任何技能覆盖。
有覆盖的请求由 Claude Sonnet 5 依据每个技能自己的 SKILL.md 写出,所以
标签可信,而请求也比用户实际发来的更容易。
这 173 条无覆盖的请求都是用来惩罚乱猜的:85 条日常请求、42 条没有技能能服务的技术问题(解释一下什么是 monad),以及 46 条索要 某个名册里没有的技能——比如在一个只覆盖 X、别的什么都没有的名册上 把这条发到 Mastodon。
打分只看 agent 的第一次响应。两个数字都是错误率,所以各自越低 越好:
- 错误加载:在有覆盖的请求里,第一次
skill_view调用 不是覆盖技能的那部分占比。整轮什么也没加载也算一次失误。 - 多余加载:在无覆盖的请求里,agent 调用了
skill_view的那部分占比。
REQUESTS = json.loads(Path("requests.json").read_text(encoding="utf-8"))
POSITIVES = [p for p in REQUESTS if p["gold"]]
NEGATIVES = [p for p in REQUESTS if not p["gold"]]
print(
f"{len(REQUESTS)} requests: {len(POSITIVES)} covered by a skill "
f"({len({p['gold'] for p in POSITIVES})} distinct skills), {len(NEGATIVES)} covered by none"
)
print(f"\ncovered [{POSITIVES[0]['gold']}] {POSITIVES[0]['text']}")
print(f"uncovered {NEGATIVES[0]['text']}")
488 requests: 315 covered by a skill (171 distinct skills), 173 covered by none
covered [1password] I've got a config.yaml with `{{ op://app-prod/db/password }}` placeholders in it — can you set up my project to pull the real values in at runtime instead of hardcoding them?
uncovered Add these three cards to our Trello backlog.
建议放在系统提示词单独的一个块里,排在名册之后而不是 名册里面,这样名册文本每一轮都完全一致,前缀缓存才保得住。
agent 有一套极简的工具,包括用自由文本名加载技能的 skill_view。
名字必须和技能完全一致,才算一次正确加载。
# Verbatim from hermes-agent tools/skills_tool.py:SKILL_VIEW_SCHEMA.
SKILL_VIEW_DESCRIPTION = (
"Skills allow for loading information about specific tasks and workflows, as "
"well as scripts and templates. Load a skill's full content or access its "
"linked files (references, templates, scripts). First call returns SKILL.md "
"content plus a 'linked_files' dict showing available references/templates/"
"scripts. To access those, call again with file_path parameter."
)
TOOLS = [
{
"name": "skill_view",
"description": SKILL_VIEW_DESCRIPTION,
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The skill name."}
},
"required": ["name"],
},
},
{
"name": "terminal",
"description": "Run a shell command on the user's machine and return its output.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
{
"name": "read_file",
"description": "Read a file from the user's filesystem.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
{
"name": "web_search",
"description": "Search the web and return result snippets.",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
]
@json_cache
def run_turn(model: str, arm: str, request: str, suggestion: str) -> dict:
"""One measured turn. ``arm`` is in the key so each arm samples independently."""
system = [
{"type": "text", "text": CATALOG_PROMPT, "cache_control": {"type": "ephemeral"}}
]
if suggestion:
system.append({"type": "text", "text": suggestion}) # after the breakpoint
response = agent.messages.create(
model=model,
max_tokens=1024,
system=system,
tools=TOOLS,
messages=[{"role": "user", "content": request}],
)
usage = response.usage
return {
"loaded": [
str(block.input.get("name", ""))
for block in response.content
if block.type == "tool_use" and block.name == "skill_view"
],
"input_tokens": usage.input_tokens or 0,
"output_tokens": usage.output_tokens or 0,
}
def summarise(turns: dict[str, dict]) -> dict[str, float]:
"""Two failure rates: wrong loads on covered requests, needless ones on uncovered."""
hits = [turns[p["text"]]["loaded"][:1] == [p["gold"]] for p in POSITIVES]
over = [bool(turns[p["text"]]["loaded"]) for p in NEGATIVES]
return {
# both metrics are errors, so the two columns read the same direction
"wrong_load": 1 - sum(hits) / len(hits),
"needless_load": sum(over) / len(over),
}
def run_arm(arm: str, suggestions: dict[str, str]) -> dict[str, dict]:
"""One measured turn per request, in a small pool. 488 calls."""
texts = [request["text"] for request in REQUESTS]
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
turns = pool.map(
lambda t: run_turn(AGENT_MODEL, arm, t, suggestions.get(t, "")), texts
)
return dict(zip(texts, turns))
agent 先只带着自己的名册跑一遍,也就是它今天的做法。它的两个错误率 就是这份 cookbook 其余部分拿来做对照的基线。
baseline = run_arm("baseline", {})
base_scores = summarise(baseline)
print(
f"wrong loads {base_scores['wrong_load']:.1%} ({len(POSITIVES)} covered requests)"
)
print(
f"needless loads {base_scores['needless_load']:.1%} ({len(NEGATIVES)} uncovered requests)"
)
# where the wrong loads land: a neighbour of the right skill, or somewhere unrelated?
misses = [
(p["gold"], baseline[p["text"]]["loaded"][0])
for p in POSITIVES
if baseline[p["text"]]["loaded"] and baseline[p["text"]]["loaded"][0] != p["gold"]
]
same_category = sum(
1
for gold, got in misses
if got in BY_NAME and BY_NAME[got]["category"] == BY_NAME[gold]["category"]
)
print(
f"\nof {len(misses)} wrong first picks, {same_category} came from the right skill's own "
f"category"
)
wrong loads 16.8% (315 covered requests)
needless loads 9.8% (173 uncovered requests)
of 36 wrong first picks, 10 came from the right skill's own category
错误加载落在正确技能自己分类里的比例,远高于随机水平,所以难的地方 在于把几个长得像的区分开。agent 已经在差不多对的地方找了。
第 3 步:给整个名册排序
一次请求带着两类问题:
which是一道Choice问题, 覆盖全部 182 个技能名,以索引描述作为每个选项的 criteria(也就是 agent 自己拿到的那段文本)。它的概率就是排序结果。- 三道关于请求的
Noul问题, 列在下面,各自用不同的方式问:它想要的是执行动作,还是给出解释。prose_suffices的方向相反。它们的均值决定要不要给出任何建议, 低于 0.30 就什么都不建议。
两者在同一次请求里发出,所以排序和检查只花一个来回。
这三道题要写成在问「是否想要一个动作」。一个关于主题内容的问题 是没法把解释一下什么是 monad 和一个真需要技能的请求区分开的,因为两者 都跟软件有关。
一道 Choice 问题装下这么大的名册很轻松。再大几倍,你就该
把它拆成几块分别排序,再对这一批胜出者跑同样的短名单步骤。
CHOICE_INSTRUCTIONS = (
"Which of these skills, if any, is the right one to load to help with the "
"user's latest request?"
)
GATE_QUESTIONS = {
"acts_on_user_system": (
"Is the assistant being asked to act on the user's files, accounts, devices, "
"or online services, rather than only to explain or advise?"
),
"would_follow_documented_procedure": (
"Would a careful expert answering this consult a specific documented procedure "
"or set of commands, rather than answering from general understanding?"
),
"prose_suffices": (
"Could a knowledgeable generalist fully satisfy this request in prose, with "
"no tools, no documentation, and no access to the user's files or accounts?"
),
}
INVERTED = {"prose_suffices"} # a yes here points away from needing a skill
def build_state(request: str) -> dict:
return {"request": request, "recent_context": ""}
@json_cache
def rank_wide(request: str) -> dict:
"""Request 1: rank all 182 skills, and score the request for whether a skill applies."""
questions = {
"which": Choice(
instructions=CHOICE_INSTRUCTIONS,
criteria={skill["name"]: skill["description"] for skill in ROSTER},
)
}
for key, text in GATE_QUESTIONS.items():
questions[f"gate::{key}"] = Noul(instructions=text)
started = perf_counter()
response = client.system_one(
state=build_state(request), questions=questions, model=TYPESAFE_MODEL
)
ranked = sorted(
response.answers["which"].probabilities.items(), key=lambda kv: -kv[1]
)
values = {
key.removeprefix("gate::"): answer.noul
for key, answer in response.answers.items()
if key.startswith("gate::")
}
oriented = [(1.0 - v) if k in INVERTED else v for k, v in values.items()]
return {
"ranked": ranked[
:12
], # more than any shortlist needs, and keeps the cache small
"gate": sum(oriented) / len(oriented),
"values": values,
"seconds": round(perf_counter() - started, 2),
"input_tokens": response.usage.input_tokens or 0,
"output_tokens": response.usage.output_tokens or 0,
}
DEMO = [
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so it syncs"
" to my phone? Just write it up in whatever editor pops up.",
"Can you put together a pitch deck skeleton (cover, situation overview, comps, precedent"
" transactions, DCF, LBO) as a .pptx, using our firm-template.pptx for branding and"
" footnoting each valuation number back to the cell it came from in the model?",
"Post this announcement to my Mastodon account.",
]
for request in DEMO:
wide = rank_wide(request)
verdict = "suggest" if wide["gate"] >= GATE_THRESHOLD else "stay quiet"
print(f'"{request[:78]}"')
print(f" needs a skill {wide['gate']:.2f} -> {verdict} ({wide['seconds']}s)")
for name, probability in wide["ranked"][:SHORTLIST]:
print(f" {probability:.3f} {name:<38}{BY_NAME[name]['description']}")
print()
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so "
needs a skill 0.75 -> suggest (0.31s)
0.990 apple-notes Manage Apple Notes via memo CLI: create, search, edit.
0.010 computer-use Drive the user's desktop in the background — clicking, ty...
0.000 concept-diagrams Generate flat, minimal educational SVG visuals as HTML.
"Can you put together a pitch deck skeleton (cover, situation overview, comps, "
needs a skill 0.76 -> suggest (0.16s)
0.700 powerpoint Create, read, edit .pptx decks, slides, notes, templates.
0.300 pptx-author Build PowerPoint decks headless with python-pptx.
0.000 chroma Embedding database for RAG and semantic search.
"Post this announcement to my Mastodon account."
needs a skill 0.78 -> suggest (0.16s)
0.550 xurl X/Twitter via xurl CLI: raw post search, posting, DM, media.
0.140 computer-use Drive the user's desktop in the background — clicking, ty...
0.080 openhands Delegate coding to OpenHands CLI (model-agnostic, LiteLLM).
Notes.app 那条请求没有歧义,排在第一的选项就是对的。Mastodon 那条 则无药可救:三道题都说需要一个技能,因为往一个账号发帖是一个 动作;而名册里有发到 X 的技能、没有发到 Mastodon 的,最接近的那个技能 照样会胜出。
剩下那份 deck。领先的两个都是 .pptx 技能,而在 60 个字符下,这道宽范围 Choice
问题把编辑技能排在创作技能前面——尽管请求要的是
创作一份 deck。
第 4 步:重排前三名
三个选项留出了空间,可以放下完整描述再加上每个技能自己
SKILL.md 的开头,于是第二次请求把同一个问题放到更好的证据上:
which是一道覆盖短名单的Choice问题,以那段更长的文本作为每个 选项的 criteria。fits::{name}是每个候选一道Noul问题:这个技能是不是正好 做请求要的那件事?每道都独立作答,所以它们可能全都偏低; 如果短名单里最高的那个低于 0.30,整份短名单就被丢掉。
RERANK_INSTRUCTIONS = (
"Exactly one of these skills is the right one to load for the user's latest "
"request. Which one? Read what each actually does, not just its name."
)
def rerank_criteria(names: tuple[str, ...], excerpt: int) -> dict[str, str]:
return {
name: f"{BY_NAME[name]['description_full']} — {BY_NAME[name]['body'][:excerpt]}"
for name in names
}
def rerank_questions(names: tuple[str, ...], excerpt: int) -> dict:
questions = {
"which": Choice(
instructions=RERANK_INSTRUCTIONS, criteria=rerank_criteria(names, excerpt)
)
}
for name in names:
questions[f"fits::{name}"] = Noul(
instructions=(
f"Does the skill '{name}' do the specific thing the user's request asks "
f"for? It is described as: {BY_NAME[name]['description_full']}"
)
)
return questions
@json_cache
def rerank(request: str, names: tuple[str, ...], excerpt: int) -> dict:
"""Request 2: the same Choice over a shortlist, plus one absolute noul per candidate."""
started = perf_counter()
response = client.system_one(
state=build_state(request),
questions=rerank_questions(names, excerpt),
model=TYPESAFE_MODEL,
)
return {
"winner": response.answers["which"].choice,
"fits": {
key.removeprefix("fits::"): answer.noul
for key, answer in response.answers.items()
if key.startswith("fits::")
},
"seconds": round(perf_counter() - started, 2),
"input_tokens": response.usage.input_tokens or 0,
"output_tokens": response.usage.output_tokens or 0,
}
for request in DEMO:
wide = rank_wide(request)
if wide["gate"] < GATE_THRESHOLD:
print(f'"{request[:78]}"\n scored too low, nothing suggested\n')
continue
shortlist = tuple(name for name, _ in wide["ranked"][:SHORTLIST])
result = rerank(request, shortlist, EXCERPT_CHARS)
best = max(result["fits"].values())
verdict = result["winner"] if best >= FITS_THRESHOLD else "nothing fits"
print(f'"{request[:78]}"')
print(f" was {shortlist[0]} -> {verdict} ({result['seconds']}s)")
for name in shortlist:
print(f" fits {result['fits'][name]:.2f} {name}")
print()
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so "
was apple-notes -> apple-notes (0.12s)
fits 0.60 apple-notes
fits 0.54 computer-use
fits 0.01 concept-diagrams
"Can you put together a pitch deck skeleton (cover, situation overview, comps, "
was powerpoint -> pptx-author (0.09s)
fits 0.73 powerpoint
fits 0.38 pptx-author
fits 0.02 chroma
"Post this announcement to my Mastodon account."
was xurl -> xurl (0.09s)
fits 0.56 xurl
fits 0.38 computer-use
fits 0.05 openhands
两个 .pptx 技能在各自带上自己的文本后就分开了:那份 deck 请求改判
给创作技能。
这里的 fits noul 和 Choice 是分歧的:noul 给编辑技能打的分更高,
而 Choice 选的是创作技能。它们在决定的是不同的事。Choice
定的是哪一个技能,noul 定的是到底要不要说点什么。
Mastodon 那条请求两道检查都通过了:它最好的 fits noul 高于 0.30,于是这份
方案为一条关于 Mastodon 的请求建议了 X 技能。大多数类似的请求都能被抓出来。
第二轮只能拒绝宽范围排序交给它的东西,而这里交给它的是三个
擦边球。
下面这个函数就是整套方案:两次请求、两个阈值,最多返回一个 技能名。
要把它指向你自己的名册,替换 hermes_roster.json 即可。上面每个问题都只
从这个文件里读 name、description、description_full 和 body,除此之外没有别的地方
知道 Hermes。
def suggest(request: str) -> tuple[str, ...]:
"""At most one skill name for a request, or () for "nothing here applies"."""
wide = rank_wide(request)
if wide["gate"] < GATE_THRESHOLD:
return ()
shortlist = tuple(name for name, _ in wide["ranked"][:SHORTLIST])
result = rerank(request, shortlist, EXCERPT_CHARS)
if max(result["fits"].values()) < FITS_THRESHOLD:
return ()
return (result["winner"],)
def suggestion_block(names: tuple[str, ...]) -> str:
"""What gets appended after the roster, in the suggestion.
This string is a measured input rather than prose: it goes to the agent, so it is part
of every graded turn's cache key. Editing a word here silently invalidates the shipped
results and costs a live re-run to restore them.
"""
body = (
f"Relevant to the current request: {', '.join(names)}. Ignore this if it does not "
"fit what the user actually asked for."
if names
else "No skill in the roster appears relevant to this request."
)
return f"\n\n<skill_relevance>\n{body}\n</skill_relevance>"
print(suggestion_block(suggest(DEMO[1])))
print(suggestion_block(suggest(DEMO[2])))
<skill_relevance>
Relevant to the current request: pptx-author. Ignore this if it does not fit what the user actually asked for.
</skill_relevance>
<skill_relevance>
Relevant to the current request: xurl. Ignore this if it does not fit what the user actually asked for.
</skill_relevance>
第 5 步:衡量建议的效果
488 条请求各自送给 agent 三次,每次一个被测轮次。各次运行 只差在告诉 agent 什么:
| 系统提示词里放什么 | |
|---|---|
| 单独的 agent | 什么都不放 |
| 带建议的 agent | suggest() 返回的东西 |
| 直接拿到答案的 agent | 覆盖技能的名字;没有覆盖技能时则是「没有适用的」 |
第三行是做不到的;它是另外两行拿来对照的天花板。
那句建议的措辞同时在干两件事。它说明建议可以忽略, 因为推得越用力,错误建议也越会被照办,而一条错误建议比没有建议更糟。而且, 一轮里即使没有任何可建议的东西,也仍然会发一句说明;什么都不发 的话,名册自己那句「宁可多加载」的指令就没人制衡了。
texts = [request["text"] for request in REQUESTS]
with ThreadPoolExecutor(max_workers=WORKERS) as pool: # up to 488 x 2 TypeSafe requests
suggested = dict(zip(texts, pool.map(suggest, texts)))
WIDE = {text: rank_wide(text) for text in texts} # all cache hits now; reused below
arms = {
"baseline": {},
"TypeSafe": {
request["text"]: suggestion_block(suggested[request["text"]])
for request in REQUESTS
},
"oracle": {
request["text"]: suggestion_block((request["gold"],) if request["gold"] else ())
for request in REQUESTS
},
}
scores = {
arm: summarise(run_arm(arm, suggestions)) for arm, suggestions in arms.items()
}
print(f"{'run':<10}{'wrong loads':>13}{'needless loads':>16}")
for arm, row in scores.items():
print(f"{arm:<10}{row['wrong_load']:>13.1%}{row['needless_load']:>16.1%}")
def fewer(metric: str) -> str:
"""The plain ratio between the two arms' error rates."""
return f"{scores['baseline'][metric] / scores['TypeSafe'][metric]:.1f}x fewer"
print(
f"\nbaseline -> TypeSafe: {fewer('wrong_load')} wrong loads, "
f"{fewer('needless_load')} needless ones"
)
run wrong loads needless loads
baseline 16.8% 9.8%
TypeSafe 7.3% 4.0%
oracle 2.5% 1.2%
baseline -> TypeSafe: 2.3x fewer wrong loads, 2.4x fewer needless ones
moved = [
(
baseline[p["text"]]["loaded"][:1] == [p["gold"]],
run_turn(AGENT_MODEL, "TypeSafe", p["text"], arms["TypeSafe"][p["text"]])[
"loaded"
][:1]
== [p["gold"]],
)
for p in POSITIVES
]
print(
f"of {len(POSITIVES)} covered requests: {sum(not b and a for b, a in moved)} the suggestion "
f"fixed, {sum(b and not a for b, a in moved)} it broke"
)
of 315 covered requests: 37 the suggestion fixed, 7 it broke
建议修好的请求远多于它弄坏的,但它确实也弄坏了一些 agent 本来 答对的。一条自信的错误建议比完全没有建议更有说服力, 这就是把建议放到这一轮前面的代价。
SURFACE, INK, INK2, MUTED = "#fcfcfb", "#0b0b0b", "#52514e", "#898781"
GRID, AXIS, BLUE, ORANGE = "#e1e0d9", "#c3c2b7", "#2a78d6", "#eb6834"
ARM_COLOR = {"baseline": BLUE, "TypeSafe": ORANGE, "oracle": MUTED}
def style(ax):
ax.set_facecolor(SURFACE)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(AXIS)
ax.tick_params(colors=MUTED, labelcolor=INK2, labelsize=9)
ax.set_axisbelow(True)
panels = [
("wrong_load", f"wrong loads\n{len(POSITIVES)} covered requests"),
("needless_load", f"needless loads\n{len(NEGATIVES)} uncovered requests"),
]
names = list(scores)
fig, axes = plt.subplots(1, 2, figsize=(8.4, 3.6), facecolor=SURFACE)
for ax, (metric, title) in zip(axes, panels):
style(ax)
ax.grid(axis="y", color=GRID, linewidth=0.8)
values = [scores[arm][metric] for arm in names]
bars = ax.bar(
names,
values,
0.58,
color=[ARM_COLOR[arm] for arm in names],
# the oracle is a ceiling, not a competitor: gray, and hatched so it never depends
# on colour alone
hatch=["", "", "///"],
edgecolor=SURFACE,
linewidth=1.2,
)
ax.bar_label(
bars,
labels=[f"{v:.1%}" for v in values],
padding=3,
color=INK2,
fontsize=9,
)
ax.set_title(title, loc="left", color=INK2, fontsize=9.5)
ax.set_ylim(0, max(values) * 1.28)
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1, decimals=0))
ax.set_ylabel("% of those requests - lower is better", color=INK2, fontsize=9)
fig.suptitle(
f"Hermes' {len(ROSTER)}-skill roster, {len(REQUESTS)} requests, {AGENT_MODEL}",
x=0.02,
ha="left",
color=INK,
fontsize=11,
)
fig.tight_layout()
display(fig)
plt.close(fig)
结果说明了什么
- 错误加载从 16.8% 降到 7.3%,多余加载从 9.8% 降到 4.0%,这差不多填上了 「从截断的索引去猜」和「直接拿到答案」之间的大部分差距。
- 有些 agent 本来自己答对的请求,加上建议后反而错了。具体数量见上。
当你的 agent 带着一份庞大的名册时,就照这个形状来:先对所有技能做一次 廉价的排序,再仔细看两三个。每一步都有可能空手而归。
在 playground 里打开它
为第 4 步里那份 deck 请求生成一个 playground 链接,用每个候选的完整 描述和正文节选作为它的 criteria。
demo_shortlist = tuple(name for name, _ in rank_wide(DEMO[1])["ranked"][:SHORTLIST])
playground_link = make_playground_link(
build_state(DEMO[1]),
rerank_questions(demo_shortlist, EXCERPT_CHARS),
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 [Open the shortlist + questions in the TypeSafe playground]({playground_link})"
)
)
在 TypeSafe playground 中打开短名单 + 问题 →
接下来
同样的形状在别处也会出现: 意图路由是用来路由到 处理器而不是技能的,置信度讲 怎么选那两个阈值, 推测式扇出讲怎么把每个 问题放进一次请求。