Agent 决策:挑技能、转函数调用
💡 一句话总结:用 Jev 掌管 Agent 决策 —— 一道 Choice 给 194 个技能排名、两道闸门决定说不说、自然语言请求转成参数永远合法的类型化函数调用。
agent 该加载哪个技能、该调用哪个函数,这类问题丢给 LLM,得到的是一段要再解析的文本;丢给 Jev,得到的是类型化答案 + 概率,决定权留在你的代码里。
官方用两个 cookbook 拆解了这套 Jev Agent 决策配方:Skill suggestion 给挂了 194 个技能的 agent 做技能把关,Function calling 把自然语言交易请求转成对普通类型化函数的调用。两者共用同一套哲学:Jev 做判断,代码做决策。
用户这一轮请求
│
▼ TypeSafe 请求 1(排名 + 闸门,同一份 state 并行评估)
┌──────────────────────────────────────────────┐
│ 一道 Choice:194 个技能选哪个? │
│ → probabilities 就是排名,代码取前三 │
│ 三道 Noul:要动手吗(三种不同问法) │
│ 一道 Noul:点名了具体工具 / 模板 / 方法吗? │
└──────────────────────────────────────────────┘
│
▼ 代码:gate ≥ 0.30 或 named ≥ 0.50?
否 ──┤── 建议什么都不加载(还要发一句「没有相关技能」)
│ 是
▼ TypeSafe 请求 2(只看前三,带完整描述 + SKILL.md 开头)
┌──────────────────────────────────────────────┐
│ 一道 Choice:三个里到底是哪个? │
│ 三道 Noul:这个技能真干这件事吗?(各答各的) │
└──────────────────────────────────────────────┘
│
▼ 代码:最好的 fits ≥ 0.30?
否 ──┤── 建议什么都不加载
│ 是
▼
<skill_relevance> 块追加在 roster 之后 → agent 保留自己的判断为什么要在 agent 的判断前面再放一道判断?官方开门见山:大技能目录的 agent 几乎是在没有信息的情况下做选择——目录以索引形式抵达,每行一个技能,描述被截断以免挤掉对话;截到 60 字符后,编辑 .pptx 的 Office 技能和建 deck 的金融技能读起来都是「PowerPoint 工具」。压根没有合适技能的回合,它也可能硬加载一个,因为一列名字本身就在诱导猜。
本课约 20 分钟。页内全部实测数字出自官方 cookbook 的录制数据:技能建议用 jev-1.14.0(被测 agent claude-haiku-4-5-20251001,2026-10-30 渲染),函数调用用 jev-1.12;录制文件随官方仓库发布,零 key 就能回放核对。
学完你能做什么
- 给技能型 agent 加一道「该加载哪个技能」的把关:两次请求(先一道 Choice 给 194 个技能排名、再复查前三),官方实测错误加载 15.7% → 7.9%、无用加载 14.0% → 5.2%
- 写出
suggest():至多返回一个技能名,配suggestion_block()包成<skill_relevance>块追加进 system prompt——agent 保留完整索引和自己的判断,建议只说「先看哪个」 - 把自然语言请求转成类型化函数调用:
closed_sets读函数签名,Literal→ Choice、list[Literal[...]]→ 每成员一道 Noul、bool→ Noul,54 道问题一次请求问完,进函数的永远是签名里的合法值 - 读懂两种置信度用法:函数调用的 confidence = 整次调用里最没把握的那个判断(一个错参数毁一锅);挑技能只关心「选最优」时直接取最高概率选项,不必到处设 confidence 阈值
为什么用 Jev,而不是让 agent 自己挑?
| 能力 | LLM 从索引里自己挑 / 生成 JSON | Jev 结构化决策 |
|---|---|---|
| 分得清「建 deck 的」和「改 deck 的」吗 | ❌ 索引描述截到 60 字符,两个 .pptx 技能都读作 PowerPoint 工具,官方实测 15.7% 加载错 | ✅ 复查请求带上完整描述 + SKILL.md 开头 700 字符,deck 请求从 pptx-author 翻到 microsoft-office |
| 没有合适技能时敢闭嘴吗 | ❌ 一列名字诱导瞎猜,官方实测 14.0% 硬加载 | ✅ 四道 Noul 把门,复查全员不合格就建议「什么都不加载」(5.2%) |
| 函数参数合法吗 | ❌ LLM 生成 JSON 再解析,字段值可能编造、解析代码越写越厚 | ✅ 封闭集参数映射成 Choice / Noul,官方原话:进函数的都是函数接受的值 |
| 把握多大敢执行 | ❌ LLM 自报置信度是文本,还得再解析一次 | ✅ RLCD 校准训练(标 0.2 的事长跑约 20% 发生),confidence = 最弱那个判断,低于阈值就转确认 |
| 改决策政策 | ❌ 改 prompt、全量重跑,review 时翻不动 | ✅ 两个阈值 + 问题措辞集中在代码常量里,官方建议放在一个文件方便人审 |
你现在的困境
- 「agent 挂了 194 个技能,索引里每个描述只有 60 个字符——让它改一份现有 deck,它加载了建新 deck 的技能;压根没有合适技能的回合,它也硬加载一个」
- 「函数调用靠 LLM 生成 JSON 再解析,参数值偶尔编造、字段名对不上,解析代码越写越厚」
- 「想让模型自己判断『下一步该干什么』,答案是一段文本,阈值、重试、兜底全散在 prompt 里,review 的时候翻不动」
什么时候用这一招
当你需要:
- agent 的技能 / 工具目录大到描述被截断,错载和无用加载开始吃掉可靠性
- 把用户的自然语言请求转成对既有类型化函数的调用,参数从固定列表里来
- 「该不该做」的阈值明明白白放在代码里,改政策不重写 prompt
而且不想:
- 把整份目录全文塞进 system prompt——官方原话:那会让技能选择退化,还给会话其余部分带来 context rot
- 每回合多付一次完整 LLM 生成调用当裁判
- 解析 LLM 吐出来的 JSON
🎒 开始前的准备
- 完成了 Choice 选择题:知道
criteria选项 map 和probabilities——本页第一道请求就是一道 194 选项的 Choice(上限 255) - 完成了 并行提问与投机扇出:知道「代码必须拿到上一个答案才能构造下一个请求」才算真依赖——本页的两次请求正是官方三个真二请求案例之一
- 跑通过安装与第一个请求:
.venv里有typesafe-sdk,TYPESAFE_API_KEY在环境变量里 - (可选,零 key 复现)下载官方数据:typesafe-public-examples 仓库
cookbooks/skill_suggestion目录下的hermes_roster.json、requests.json、json_cache.json,和cookbooks/function_calling目录下的trader.py、dispatch.py、spec.json、json_cache.json——照官方代码原样跑,一个 key 都不用 - 想跑技能建议的完整实测(第 4 步)才需要
ANTHROPIC_API_KEY:被测 agent 是 Anthropic 模型;只跟做第 2、3 步的suggest()的话,TYPESAFE_API_KEY一个就够 - 知道本页数字的出处:技能建议录制用
jev-1.14.0(2026-10-30 渲染),函数调用录制用jev-1.12;你现在 live 跑会用jev-latest(当前指向jev-1.14.0),概率会有差异——对着你锁定的版本调阈值
跟我做
第 1 步:认识技能目录与裸 agent 基线(60 字符索引的失败模式)
为什么:官方先把「agent 自己挑」的失败模式摆出来:Hermes 的 194 个技能以索引形式进 system prompt,每行一个技能、描述被截到 60 字符。
先把两个 cookbook 的数据拉到工作目录(依赖两行装齐:两边的官方 pip 行原本都不含 typesafe-sdk,这里补上):
mkdir agent-decisions && cd agent-decisions
base=https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks
curl -sLO $base/skill_suggestion/hermes_roster.json
curl -sLO $base/skill_suggestion/requests.json
curl -sLO $base/skill_suggestion/json_cache.json
curl -sLO $base/function_calling/trader.py
curl -sLO $base/function_calling/dispatch.py
curl -sLO $base/function_calling/spec.json
curl -sLO $base/function_calling/json_cache.json
pip install typesafe-sdk anthropic matplotlib ipython polars numpy 'cooksafe>=0.2.0,<0.3.0'hermes_roster.json 是 NousResearch/hermes-agent 在一个钉死 commit 上的 194 个技能:每条记录有 name、category、索引里那样的截断描述、完整描述,和 SKILL.md 的开头。加载并看官方的统计输出(官方 Step 1 单元节选):
import json
from collections import defaultdict
from pathlib import Path
ROSTER = json.loads(Path("hermes_roster.json").read_text(encoding="utf-8"))
BY_NAME = {skill["name"]: skill for skill in ROSTER}
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):
skills = sorted(by_category[category], key=lambda s: s["name"])
if len(skills) == 1: # a one-skill category is a single top-level row
lines.append(f" - {skills[0]['name']}: {skills[0]['description']}")
continue
lines.append(f" {category}:")
lines.extend(f" - {skill['name']}: {skill['description']}" for skill in skills)
return "\n".join(lines)
widths = [len(skill["description"]) for skill in ROSTER]
print(f"{len(ROSTER)} skills in {len({s['category'] for s in ROSTER})} categories")
print(f"index description: {sum(widths) / len(widths):.0f} characters on average, "
f"{max(widths)} at most")你应该看到(官方发布):
194 skills in 32 categories
index description: 55 characters on average, 60 at mostrequests.json 是 452 条单轮请求:280 条恰被一个技能覆盖(145 个不同技能),172 条什么技能都不覆盖。覆盖请求由 Claude Sonnet 5 从每个技能自己的 SKILL.md 写出;172 条不覆盖的全是写给「惩罚瞎猜」的——84 条日常请求、42 条没有技能服务的技术问题(explain what a monad is)、46 条要目录里没有的东西(目录只覆盖 X 时的 post this to Mastodon)。
裸 agent 基线(452 请求逐条跑,被测模型钉死为 claude-haiku-4-5-20251001):
wrong loads 15.7% (280 covered requests)
needless loads 14.0% (172 uncovered requests)
of 26 wrong first picks, 11 came from the right skill's own category两个指标都是错误率,越低越好:wrong load 是覆盖请求里第一次 skill_view 调的不是覆盖技能(压根没加载也算);needless load 是不覆盖请求里调了 skill_view。26 个错载里 11 个落在正确技能自己的分类——agent 大致看对了地方,难的是把几个长相相似的分开。
💡 这就是「问错了层」的样子
官方 Agent Skill 的 SKILL.md 把话说得很直白:让 Jev 直接做决定的问题(「这张工单该升级吗?」「agent 下一步该干什么?」)通常说明问题问错了层——把决策拆成它依赖的判断,决策本身写进代码,可见、可测。完整哲学见与 Claude Code 配合开发,本页全程照此执行:Jev 排名、把关,代码持有阈值和「说不说」的决定权。
- 我能说出两个基线错误率各测什么,以及 26 个错载里 11 个来自正确技能自己的分类意味着什么
第 2 步:第一道请求——一道 Choice 排名 194 个技能,四道 Noul 把门
为什么:排名和「这回合要不要技能」两个判断共享同一份 state,官方把它们装进一次请求——Choice 的 probabilities 就是排名,四道 Noul 负责闸门。
一道 Choice 装下 194 个选项绰绰有余(上限 255);官方还留了话:再大几倍就分块排名、块胜者重排。选项的 criteria 就用 agent 自己看到的索引描述(同一段文本),问题的 instructions 原文如下:
CHOICE_INSTRUCTIONS = (
"Which of these skills, if any, is the right one to load to help with the "
"user's latest request?"
)闸门是四道 Noul:三道从不同角度问「这回合要动手吗」,外加一道问「点名了具体方法吗」——一个回合需要技能的原因只有两个:要么要对用户的文件 / 账户 / 设备动手,要么点名了一个技能拥有的方法(meme 模板、Suno 提示词)。
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
NAMED_QUESTION = (
"Does the request ask for a specific named tool, format, template, or method to be "
"used, rather than for general help?"
)
GATE_THRESHOLD = 0.30 # mean of the three action nouls; at or above it, a skill is wanted
NAMED_THRESHOLD = 0.50 # the "names a method" noul; at or above it, a skill is wanted too
def build_state(request: str) -> dict:
return {"request": request, "recent_context": ""}
@json_cache
def rank_wide(request: str) -> dict:
"""Request 1: rank all 194 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)
questions["named"] = Noul(instructions=NAMED_QUESTION)
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],
"gate": sum(oriented) / len(oriented),
"named": response.answers["named"].noul,
}
def needs_skill(wide: dict) -> bool:
"""Either reason is enough: an action is wanted, or a named method is asked for."""
return wide["gate"] >= GATE_THRESHOLD or wide["named"] >= NAMED_THRESHOLD三个设计点值得盯住:
- 动作题问「要不要动手」,不问「主题是什么」。主题题分不开 explain what a monad is 和需要技能的请求——两个都是软件。
prose_suffices是反向题:它答 yes 恰恰说明不需要技能,所以取1.0 - v再进平均。named不进平均、单独开门。官方原话:三个动作答案会把它票死(outvote);点名了方法但不动手(做 meme)的回合,只能靠它捞进来。
needs_skill 是真值表的两个输入:动作闸门均值 ≥ 0.30 或 点名 ≥ 0.50,任一过线才继续;两个都低就建议什么都不加载。
对官方的四条 DEMO 请求跑一遍(输出为官方发布值):
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so "
action 0.83, names a method 0.88 -> suggest (0.14s)
0.990 apple-notes Manage Apple Notes via memo CLI: create, search, edit.
0.010 computer-use Drive the desktop background-first; escalate on signal.
0.000 apple-reminders Apple Reminders via remindctl: add, list, complete.
"I've got a client pitch deck as a .pptx template - I need to duplicate slide 4"
action 0.73, names a method 0.64 -> suggest (0.25s)
0.730 pptx-author Build PowerPoint decks headless with python-pptx.
0.270 microsoft-office Create, read, and edit Word, PowerPoint, and Excel files.
0.000 apple-notes Manage Apple Notes via memo CLI: create, search, edit.
"Post this announcement to my Mastodon account."
action 0.86, names a method 0.61 -> suggest (0.1s)
0.280 xurl X/Twitter via xurl CLI: raw post search, posting, DM, media.
0.240 social-media-content-calendar Plan multi-platform social campaigns: briefs to posting.
0.190 computer-use Drive the desktop background-first; escalate on signal.
"Can you make me a Gru's Plan meme about a developer's plan to refactor the cod"
action 0.12, names a method 0.87 -> suggest (0.12s)
1.000 meme-generation Create meme PNGs from templates with Pillow text overlay.
0.000 apple-notes Manage Apple Notes via memo CLI: create, search, edit.
0.000 apple-reminders Apple Reminders via remindctl: add, list, complete.四条各说明一件事:Notes 请求没有歧义,榜首就是对的;deck 请求两个 .pptx 技能领先,但 60 字符描述下建 deck 的 pptx-author 排在前面——对一个要改现有 deck 的请求,这正是错的迹象;Mastodon 请求动作闸门 0.86 照样放行(发帖是动作),但榜首 xurl 是发 X 的技能——排名救不了目录里没有的东西;meme 请求动作闸门 0.12 很低,全靠 named 0.87 开门,榜首正是 meme 技能。
💡 这就是 Jev 的独有价值
排名(一道 Choice)和闸门(四道 Noul)在同一次请求里对同一份 state 并行、独立评估——加题几乎不增加响应时间,只多花几道题的 token。官方录制里第一道请求 0.1–0.25 秒。对照「每回合摆一个 LLM 裁判」:那要为排名和把关各付一次完整生成调用,还要解析裁判吐的文本。一次请求多题的机制见并行提问与投机扇出。
💡 中文内容注意
官方说明:英语是 Jev 的主要训练语言,包括中文在内的其他语言能用但准确度较低。上面的问题全部建议保留英文原文(官方措辞经过了实测);要给中文请求做技能建议,先用你自己的数据跑一批,盯住概率分布再定阈值。
你应该看到:四条 DEMO 的闸门 / 排名与上表一致(缓存回放时逐位一致;live 跑概率会有差异,走向以你锁定的版本为准)。
第 3 步:第二道请求——复查前三,允许全员落选
为什么:这是官方三个「真二请求」案例之一:代码必须拿到第一道的排名,才能构造第二道的选项——前三名各自带上完整描述和 SKILL.md 的开头。
并行提问的原则是「能一起问就一起问」,为什么这里偏偏发第二次请求?因为第二道的选项取决于第一道的答案:每个候选要带 description_full 和 body 的开头 700 字符(roster 文件每条存 1600 字符),这些长文本只有在知道前三是谁之后才能拼进 criteria。官方 primitives 文档点名本 cookbook 是真二请求:一次请求排名全部技能,然后取前三的全文、对着更好的证据再判一次。
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
FITS_THRESHOLD = 0.30 # a shortlist whose best "does this fit" noul is under this is dropped
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_questions(names, excerpt):
questions = {
"which": Choice(
instructions=RERANK_INSTRUCTIONS,
criteria={
name: f"{BY_NAME[name]['description_full']} — {BY_NAME[name]['body'][:excerpt]}"
for name in names
},
)
}
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, names, excerpt):
"""Request 2: the same Choice over a shortlist, plus one absolute noul per candidate."""
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::")
},
}fits 三道 Noul 各答各的、可以全低——它们和 which 决定的是不同的事:which 定「三个里哪个」,fits 定「到底说不说」。把四条 DEMO 走完全程(官方发布值):
"Can you save this recipe as a new note in my 'Recipes' folder in Notes.app so "
was apple-notes -> apple-notes (0.11s)
fits 0.81 apple-notes
fits 0.50 computer-use
fits 0.02 apple-reminders
"I've got a client pitch deck as a .pptx template - I need to duplicate slide 4"
was pptx-author -> microsoft-office (0.11s)
fits 0.77 pptx-author
fits 0.76 microsoft-office
fits 0.01 apple-notes
"Post this announcement to my Mastodon account."
was xurl -> nothing fits (0.07s)
fits 0.10 xurl
fits 0.27 social-media-content-calendar
fits 0.28 computer-use
"Can you make me a Gru's Plan meme about a developer's plan to refactor the cod"
was meme-generation -> meme-generation (0.16s)
fits 0.84 meme-generation
fits 0.03 apple-notes
fits 0.01 apple-reminders.pptx 名场面在第二道请求里翻盘:两个技能一分开各带全文,deck 请求从 pptx-author 翻到编辑现有文件的 microsoft-office。注意它的 fits 两个都不低(0.77 / 0.76——两个都能对 deck 做点什么),但这道题定的不是「哪个」,翻盘靠的是 Choice。
Mastodon 请求在第二道停下:三个近似技能没有一个真干这事,最好的 fits 0.28 低于 0.30,整组放弃——这道请求两次都进了闸门,最后还是建议什么都不加载,这就是「允许全员落选」的含义。
把两个请求、两个阈值收进一个函数——整个配方就这么多:
def suggest(request: str) -> tuple[str, ...]:
"""At most one skill name for a request, or () for "nothing here applies"."""
wide = rank_wide(request)
if not needs_skill(wide):
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."""
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: microsoft-office. Ignore this if it does not fit what the user actually asked for.
</skill_relevance>
<skill_relevance>
No skill in the roster appears relevant to this request.
</skill_relevance>胜者的名字只进 system prompt 的一行。agent 保留完整索引和自己的判断,那一行只告诉它「先看哪条」。两个措辞细节都是实测出来的(官方原话):
- 建议写着「可以忽略」:把建议逼得更紧,错误建议的服从率也会一起涨——而一个错误的建议比没有更糟。
- 没有建议也发一句「没有相关技能」:什么都不发的话,roster 自己那句「宁可多加载」的指令就没有对手了。
另外注意位置:建议块加在 roster 之后而不是里面——roster 文本每回合保持一致,prefix caching 才吃得到。
- 我能说出两次请求各干什么、为什么必须发两次(第二道的选项由第一道的答案决定)
- Mastodon 请求两次过闸、最后建议什么都不加载——我能指出是哪道题把它拦下的
第 4 步:对账——452 个请求的三臂实测
为什么:官方把 452 个请求各跑三遍,三臂只差一件事:system prompt 里除了 roster 还塞了什么。
| system prompt 里塞了什么 | |
|---|---|
| agent alone(基线) | 什么都没有 |
| agent with a suggestion | suggest() 返回的建议块 |
| agent given the answer | 正确技能名,没有覆盖技能就给「nothing applies」 |
第三臂是地板:没有任何真实系统预先知道覆盖技能,所以它给前两臂划出下界——再好的选择方法也压不过它。
run wrong loads needless loads
baseline 15.7% 14.0%
TypeSafe 7.9% 5.2%
oracle 3.2% 4.1%
baseline -> TypeSafe: 2.0x fewer wrong loads, 2.7x fewer needless ones官方总结:错误加载砍掉一半还多(15.7% → 7.9%),把「从截断索引里猜」和「直接拿到答案」之间的差距关掉了过半;无用加载砍掉近三分之二(14.0% → 5.2%),几乎关完了自己那段差距。
代价也摆出来:280 个覆盖请求里,建议修好 29 个、弄坏 7 个。有些请求 agent 自己本来是对的,挂上一条自信的错误建议后被拉走——官方说这是在回合前放一道建议的代价。
你应该看到:三行两列的错误率表与上一致(缓存回放逐位一致;live 跑以你锁定的版本为准)。
- 我能说出三臂各差在哪、oracle 为什么是地板而不是参赛选手
- 我知道 7.9% 不是免费来的:29 修好、7 弄坏,所以建议措辞要带「可以忽略」
第 5 步:转函数调用——closed_sets 读签名,54 道问题一次请求
为什么:同一套哲学的第二个应用:函数一行不动,加一份 spec——每个参数一句问话、每个选项一行描述、每个函数一段描述,外加一道挑函数的问题。
官方的比喻直给:你点「大杯冰燕麦拿铁不加糖」,咖啡师不会把你的原话记下来,而是在杯子上勾四个选项。这个 cookbook 对交易 API 做同样的事——一句话进,函数名和参数值出,每个都带把握度:
"plot rolling correlation between nvda and spy for the past month"
rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo') confidence 0.91
"compare nvda amd and msft over the past three months"
compare_returns(symbols=['NVDA', 'AMD', 'MSFT'], window='3mo') confidence 0.94
"show me apple daily with volume"
plot_price(symbol='AAPL', resolution='1d', include_volume=True) confidence 0.75
"what tickers do you have"
list_symbols() confidence 1.00trader.py 是十个普通函数。参数值来自固定列表的参数在类型标注里已经是 Literal——closed_sets 读签名,把可填参数分成三种形状:
def plot_price(
symbol: Literal["SPY", "NVDA", "AMD", "AAPL", "MSFT", "TSLA"],
style: Literal["line", "candles"] = "line",
resolution: Literal["1m", "5m", "15m", "1h", "1d"] = "15m",
window: Literal["1d", "1w", "1mo", "3mo"] = "1w",
include_volume: bool = False,
moving_average: Literal["9", "20", "50"] | None = None,
log_scale: bool = False,
): ...| 形状 | 类型标注 | 变成什么问题 |
|---|---|---|
| choice | Literal[...] | 一道 Choice,选项就是合法值 |
| set | list[Literal[...]] | 每个成员一道 Noul({} 占位符替换成成员名) |
| flag | bool | 一道 Noul,≥ 0.5 为开 |
其他类型——自由文本、数字、日期——不生成问题,函数默认值照站:top_movers 的第三个参数 limit: int = 3 从不生成问题,永远保默认值 3。
spec 存在 spec.json,一句话说清「用户这么说是什么意思」——Literal 只给出 "1mo" 和 "3mo" 这两个字符串,说不出「this quarter」指后者。官方还留了一句话:spec 可以让 LLM 从签名帮你写。rolling_correlation 两个同源参数的角色问句值得逐字看:
"symbol": {
"question": "Which stock is the user asking about - the one being measured, named first?",
"options": { "SPY": "SPY, the S&P 500 index ETF, the market as a whole", "…": "…" }
},
"benchmark": {
"question": "Which stock is it being compared against - the second one named, the yardstick?",
"stated": "Does the user name a second symbol to compare against?",
"options": { "SPY": "SPY, the S&P 500 index ETF, the market as a whole", "…": "…" }
}symbol 和 benchmark 抽同一个六选一的池子,靠角色问句各归各位——「被度量的那个,先点名的」对「第二个点名的,基准尺」。选项的键就是函数收的字符串,省掉映射层。
stated 让参数可选:它是第二道是非题,问「命令里说没说这个参数」。没说(低于 0.5),整个参数不填、函数自己的默认生效。
Dispatcher 从 spec 一次性建好全部问题。之后每条命令就是一次请求:函数选择和十个函数的全部参数一起带上,代码只读选中函数的答案——问题先于「选了哪个函数」就全问了,这是投机扇出的极致形态:
assistant = Dispatcher(SPEC, TOOLS, client) # SPEC 来自 spec.json,TOOLS 来自 trader.py
print(f"{len(assistant.questions)} questions per command, among them:")
for qid in ("__tool__", "plot_price.style", "plot_price.style?", "compare_returns.symbols.NVDA"):
question = assistant.questions[qid]
print(f" {qid:<30}{question['type']:<8}{str(question['instructions'])[:64]}")你应该看到(官方发布):
54 questions per command, among them:
__tool__ choice What is the user asking the trading assistant to do?
plot_price.style choice Does the user want a plain line or candles?
plot_price.style? noul Does the user say how the chart should be drawn, such as a line,
compare_returns.symbols.NVDA noul Does the user want NVDA in the comparison?十个函数共 28 个可填参数,加上函数选择和各 stated 题,每条命令 54 道问题一次请求问完。问题写「想法」而不是「用户可能说的词」:匹配的是意思——is amd tracking nvidia lately 命中 rolling_correlation,而 tracking 和 lately 两个词在 spec.json 里压根没出现。
跑官方的 14 条命令(输出为官方发布值):
"show nvda 1h"
plot_price(symbol='NVDA', resolution='1h') confidence 0.78 tool 1.00
"plot rolling correlation between nvda and spy for the past month"
rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo') confidence 0.91 tool 1.00
"when during the day does nvda trade the most"
intraday_pattern(symbol='NVDA') confidence 0.53 tool 1.00
"what moved today"
top_movers(window='1d', direction='gainers') confidence 0.90 tool 0.90
"what tickers do you have"
list_symbols() confidence 1.00 tool 1.00
"how did the market do this week"
market_summary(window='1w') confidence 0.96 tool 0.99
"candles for tesla with a 20 period moving average"
plot_price(symbol='TSLA', style='candles', moving_average='20') confidence 0.69 tool 0.97
"compare nvda amd and msft over the past three months"
compare_returns(symbols=['NVDA', 'AMD', 'MSFT'], window='3mo') confidence 0.94 tool 1.00
"how volatile is tsla"
volatility(symbol='TSLA') confidence 0.96 tool 1.00
"biggest losers today"
top_movers(window='1d', direction='losers') confidence 0.98 tool 0.98
"worst drawdown for nvda this quarter, and chart it please"
drawdown(symbol='NVDA', window='3mo', plot=True) confidence 0.84 tool 0.84
"spy stats for the last month"
summary_stats(symbol='SPY', window='1mo') confidence 0.88 tool 0.88
"show me apple daily with volume"
plot_price(symbol='AAPL', resolution='1d', include_volume=True) confidence 0.75 tool 0.85
"is amd tracking nvidia lately"
rolling_correlation(symbol='AMD', benchmark='NVDA') confidence 0.82 tool 0.82两条长命令最能看出价值:rolling correlation 一句话填了四个参数,symbol 和 benchmark 各归各位;compare 命令把三个 ticker 放进集合、把另外三个关在门外。
- 我能说出三种封闭集形状各映射成什么问题、
limit: int为什么不出现在 54 道问题里 - 我知道 spec 的选项键为什么直接用函数收的字符串
第 6 步:读 confidence——最弱的判断决定整次调用的把握
为什么:函数调用的 confidence 报告的是整次调用里最没把握的那个判断,而不是全部判断的乘积——一个错参数就够毁掉结果。
官方原话:乘积回答的是另一个问题(「是不是每部分都对」),而且参数越多它越低,不管哪个判断真的悬。min 让你直接看到短板。对 is amd tracking nvidia lately 逐参数对账(官方发布值):
"is amd tracking nvidia lately" -> rolling_correlation(symbol='AMD', benchmark='NVDA') confidence 0.82
symbol 'AMD' p 0.87 AMD 0.87 NVDA 0.13 AAPL 0.00
benchmark 'NVDA' p 0.78 NVDA 0.92 AMD 0.08 AAPL 0.00
window omitted, default stands p 0.96
resolution omitted, default stands p 0.99
weakest argument: benchmarkwindow 和 resolution 双双省略——lately 既没说回看多远、也没说什么 bar,所以 rolling_correlation 按自己的默认跑:一个月、小时线。这就是 stated 的用处:没有它,Choice 被逼着指定一个窗口,而且会自信满满地指定。
confidence 0.82 来自最弱的 benchmark(p 0.78)。要不要执行、要不要跟用户确认一句「跟 NVDA 对比可以吗」,这条信息直接可用——阈值定在你的代码里。
最后把两个场景接回同一个原则。挑技能那半边,官方 Agent Skill 有句常见问题正好相反:如果只关心「选最优」,直接取最高概率选项即可,不必到处设 confidence 阈值;有统计算法需求就用 probabilities。两次请求把关的是「说不说」——那是用 Noul 概率本身设的闸,Noul 本来就没有单独的 confidence。
你应该看到:逐参数分解与上表一致;weakest argument: benchmark 一行指出整次调用最该怀疑的参数。
- 我能说出 confidence 为什么取 min 不是乘积,以及 stated 省略时默认值怎么来的
📋 魔法 Prompt
本页的「Prompt」是给 API 的问题与决策代码模板——抄走、替换【】占位符(带 ? 的是选填)就能用。
📝 技能排名问题组(一道 Choice + 四道 Noul)
预期效果:把「该加载哪个技能」拆成排名 + 闸门两组判断,一次请求拿到全部答案。
QUESTIONS = {
"which": Choice(
instructions="【这些技能里哪个适合用户的这一轮请求?】",
criteria={【技能名】: 【一行索引描述】}, # 194 个也放得下,上限 255
),
"gate::acts": Noul(
instructions="【用户是要对文件 / 账户 / 设备 / 线上服务动手,还是只要解释或建议?】",
),
"gate::procedure": Noul(
instructions="【认真回答这个问题的专家要不要查一份具体流程或命令集?】",
),
"gate::prose": Noul(
instructions="【一个什么都接触不到的通才,纯靠文字能把这请求答满意吗?】", # 反向题:yes = 不需要技能
),
"named": Noul(
instructions="【请求点名了具体工具、格式、模板或方法吗?】",
),
}
# 必填项:state = {"request": 【用户这轮的原话】, "recent_context": 【近期对话,可为空串】}
# 选填项:闸门题按你的领域改问法,但保持「问动作,不问主题」?
# 输出规范:probabilities 降序取前三;gate = 三道动作题的均值(prose 取 1-p 再平均);named 单独读
# 约束条件:
# ✅ 问「要不要动手」,别问「主题是什么」——主题题分不开 explain what a monad is
# ✅ 官方英文原文经过了 452 请求实测,中文化前先用自己的数据验证
# ❌ 不要问「该不该建议技能」——那是代码的活,改政策不该改问题✅ 两个阈值的建议决策(真值表收尾)
预期效果:两组概率变成「至多一个技能名」,政策全部落在看得见的常量里。
GATE_THRESHOLD = 【0.30】 # 三道动作 Noul 的均值,官方给这套请求挑的起点
NAMED_THRESHOLD = 【0.50】 # 「点名了方法」单独开门,不进平均
FITS_THRESHOLD = 【0.30】 # 复查里最好的 fits 低于它就整组放弃
SHORTLIST = 【3】 # 第一道带进第二道的候选数
def suggest(request):
wide = rank_wide(request)
if not (wide["gate"] >= GATE_THRESHOLD or wide["named"] >= NAMED_THRESHOLD):
return () # 两个理由都不成立:什么都不建议
shortlist = top(wide["ranked"], SHORTLIST)
result = rerank(request, shortlist) # 第二道:选项 = 前三的全文,真二请求
if max(result["fits"].values()) < FITS_THRESHOLD:
return () # 允许全员落选
return (result["winner"],)
# 必填项:三个阈值——官方说明它们是起点,不是默认值
# 选填项:建议块措辞保留「可以忽略」,并给「没有建议」也发一句话?
# 输出规范:至多一个技能名,或 ();包进 <skill_relevance> 块加在 roster 之后
# 约束条件:
# ✅ 建议块加在 roster 之后,别改 roster 文本——prefix caching 靠它逐回合一致
# ✅ 阈值、问题措辞集中在一个文件里,方便人审(官方 Agent Skill 的建议)
# ❌ 不要把建议写死成命令——自信的错误建议比没有更糟(实测弄坏 7 个本来对的)🔧 函数调用 spec 模板(一个函数一条目)
预期效果:签名里的封闭集参数逐个配上「问想法」的问题,进函数的值永远合法。
{
"question": "【用户想让这个助手做什么?(挑函数的那道题)】",
"functions": {
"【函数名】": {
"description": "【用户想要这个函数做什么,一段话】",
"arguments": {
"【参数名】": {
"question": "【这个参数用户要什么——按想法问,不按参数名问】",
"stated": "【用户说没说这个参数?】",
"options": {
"【合法值,即 Literal 成员】": "【这个值是什么意思,一行】"
}
}
}
}
}
}
# 必填项:每个 Literal 参数一条;list[Literal] 的 question 里放 {} 占位(每成员一道 Noul)
# 选填项:可选参数配 stated;bool 参数不需要 options(一道 Noul ≥ 0.5 为开)?
# 输出规范:Dispatcher 一次建 54 道问题(本页示例规模),每条命令一次请求
# 约束条件:
# ✅ 选项键 = 函数收的字符串,省掉映射层
# ✅ spec 可以让 LLM 从签名帮你写(官方原话),写完人审一遍
# ❌ 不要拿参数名当问题("Which resolution?")——没给命令任何可匹配的东西检查点 ✅
- 我能说出两次请求各干什么:第一道一道 Choice 排名 194 个技能 + 四道 Noul 把门;第二道只看前三、带完整描述复查、允许全员落选
- 我知道为什么必须发第二次请求:第二道的选项(前三的全文)要等第一道的排名才能构造——官方三个真二请求案例之一
- 我能复述三臂实测:错误加载 15.7% → 7.9%(砍半还多)、无用加载 14.0% → 5.2%(近三分之二);oracle 地板 3.2% / 4.1%
- 我能说出函数调用三种封闭集形状:
Literal→ Choice、list[Literal[...]]→ 每成员一道 Noul、bool→ Noul;数字、自由文本不问、保默认 - 我知道 confidence 在函数调用里取最弱判断(min)而不是乘积,也知道挑技能「只选最优」时不必到处设阈值
踩坑提醒
❌ 让 Jev 直接回答「agent 下一步该干什么」
SKILL.md 原话:这类问题通常说明问错了层。正确姿势是把决策拆成它依赖的判断——排名、动作闸门、点名闸门、fits——判断给 Jev,决策(两个阈值 + 真值表)写进代码,可见、可测。本页两道请求、四个常量就是完整示范。
❌ 到处设 confidence 阈值
官方 Agent Skill 的常见问题清单里就有这条:只关心「选最优」,直接取最高概率选项即可。有统计算法需求就用 probabilities。本页的闸门用 Noul 概率本身(Noul 没有单独的 confidence);函数调用端的 confidence 才当「把握度」用,而且只取最弱那个判断。
❌ 拿主题题当闸门
「这是不是软件相关」分不开 explain what a monad is 和一个需要技能的软件请求——两个都是软件。动作题问的是「要不要对用户的文件、账户、设备、线上服务动手」,这才分得开。反向题(prose_suffices)记得取 1-p 再进平均。
❌ 拿参数名当问题
"Which resolution?" 官方点名批评:没给命令任何可匹配的东西。问想法:"How far back is the user asking about - today, the past week, the past month, or the past three months?"——这样 this quarter 才会落到 3mo,lately 才会诚实地什么也不填。
进阶技巧(可选)
🔧 指向你自己的技能目录。 官方原话:换掉 hermes_roster.json 就行——上面每道问题只读 name、description、description_full、body 四个字段,suggest() 里没有别的 Hermes 特有的东西。
📚 更大的目录怎么办。 官方留了话:这个规模一道 Choice 绰绰有余;再大几倍就分块排名,块胜者像第 3 步那样重排。Rerank cookbook(搜索与匹配)用同样的「先短list、再重打分」两步走,便宜的第一道换成 BM25。
⏳ 零 key 回放全部数字。 两个 cookbook 的调用都录在各自的 json_cache.json 里、按输入做键,回放不发一个请求;删掉缓存文件就全走 live。技能建议的发布录制用 jev-1.14.0 + claude-haiku-4-5-20251001(2026-10-30 渲染),函数调用用 jev-1.12。
🚀 让 coding agent 帮你写这些。 装上 TypeSafe Agent Skill,把本页交给它照着做:
claude plugin marketplace add typesafe-ai/skills
claude plugin install typesafe@typesafe-ai官方建议问题与阈值常量集中在一个文件里方便人审——本页的 GATE_THRESHOLD / NAMED_THRESHOLD / FITS_THRESHOLD 和四道闸门题正是该放进那一个文件的东西。见与 Claude Code 配合开发。
本课小结
✅ 技能建议 = 两次请求 + 两个阈值:第一道一道 Choice 排名全部 194 个技能、四道 Noul 把门(三道动作题问「要不要动手」、一道问「点名了吗」);第二道只看前三、各带完整描述 + SKILL.md 开头 700 字符,fits 全低就整组放弃。产出至多一个技能名,<skill_relevance> 块加在 roster 之后。
✅ 实测收益与代价:错误加载 15.7% → 7.9%、无用加载 14.0% → 5.2%;oracle 地板 3.2% / 4.1% 说明再好也压不过「直接给答案」。280 个覆盖请求里建议修好 29、弄坏 7——建议措辞带「可以忽略」不是客气。
✅ 函数调用 = 一份 spec + 一次请求:closed_sets 读签名把封闭集参数分成 choice / set / flag 三种形状;54 道问题一次请求问完,代码只读选中函数的答案。进函数的永远是 Literal 里的合法值;数字、自由文本不问、保默认。
✅ confidence 取最弱:整次调用的把握由最没把握的那个判断决定(min,不是乘积);stated 让没说的参数整个省略、函数默认生效;weakest() 指出该怀疑哪个参数。
关键配置字段回顾:
| 字段 | 本页的值 | 说明 |
|---|---|---|
SHORTLIST | 3 | 第一道带进第二道的候选数 |
EXCERPT_CHARS | 700 | 每个候选带的 SKILL.md 开头字符数(roster 文件每条存 1600) |
GATE_THRESHOLD | 0.30 | 三道动作 Noul 均值过线即「要技能」 |
NAMED_THRESHOLD | 0.50 | 「点名了方法」单独开门,不进平均 |
FITS_THRESHOLD | 0.30 | 复查中最好的 fits 低于它就整组放弃 |
MEMBER / STATED | 0.5 | set 成员进集合 / 可选参数被填的门槛 |
TYPESAFE_MODEL | jev-1.14.0(技能)/ jev-1.12(函数调用) | 官方录制版本;live 跑锁你自己的版本 ID |
AGENT_MODEL | claude-haiku-4-5-20251001 | 被测 agent,官方注释:钉死保证分数稳定 |
相关教程:
- RAG 段落把关:只放相关内容进上下文——上一课:同款「概率 + 你代码里的阈值」收尾,把关的是进上下文的段落
- 简单记忆系统:让 Jev 决定记什么——下一课:官方定义的 harness(模型外围给它额外能力的代码)——本页给了 tool calls,那页给记忆
- 与 Claude Code 配合开发:TypeSafe Agent Skill——「判断给 Jev、决策留代码」真值表哲学的完整版与 Agent Skill 安装使用
下一课预告
本课的 harness 决定「调用哪个工具、挑哪个技能」,下一课给 harness 加上记忆:简单记忆系统:让 Jev 决定记什么。
你会学到:
- 调用后一道 Noul 闸门判断「这份请求陈述了值得记的客户事实吗」,P ≥ 0.5 才存
- 记忆存进普通 Python 字典,调用前并进 state——
ask_with_memory()与ask()同参数、同返回值,换上不用改其他代码 - 官方实测:后续工单路由 3/6 变 6/6,每次调用只多一次请求
- 官方定义的 harness 概念:模型外围给它额外能力(tool calls、循环运行)的代码——本页的
Dispatcher就是给模型加 tool calls 的那一种