组合打分:把复杂判断拆成原子分数再加权
上一课的智能路由用一道 Score 评复杂度,回答的是「这个答案敢不敢自动执行」。这一课回答另一个问题:一批东西摆在你面前,要按好几个标准挑出最好的 X 个——简历筛选、工单定级、搜索重排,都是这一类。
官方为它起了名字:Composite scoring(组合打分)。做法一句话:把复杂判断拆成独立维度,每维度一道 Score 各自打分,再用你在代码里控制的权重合并。官方模式页的案例是工程岗简历筛选:4 个维度、一次请求、两套权重、两张排名榜。全程 10 分钟。
其实你已经写过它的最小版:Score 课第 5 步用三道题算出工单优先级 0.66。本课把那三行加权式扩展成完整的排序系统——多候选人、多角色,外加一套「排名不合预期时怎么办」的调试方法。
学完你能做什么
- 用 Jev 组合打分把「这位候选人好不好」这类多维度判断拆开:四个维度各一道原子 Score,一次请求并行拿回独立分数
- 写出归一化加权的合并代码:每题除以最高级号拉到 0–1,再按权重求和——官方
scoring.py原样可用 - 用同一批原子分数服务两个角色:Senior IC 权重 0.40/0.10/0.40/0.10,Engineering Manager 权重 0.15/0.40/0.20/0.25,一次打分排两张榜
- 排名不合预期时只改权重常量本地重算,不动量表、不动 prompt;要加维度,问题字典添一道题,每个候选人的请求数不变
- 说清组合打分和上一课门控的分工:门控管「敢不敢用」,组合打分管「怎么排」
你现在的困境
- 我让大模型筛简历,prompt 里写「请综合技术深度、带团队、架构、广度打 1–10 分」。它回我一段小作文加个 7.5,下个月重跑变成 8.2——没有分布、没有依据。用人经理问「这 7.5 怎么算出来的」,我只能把小作文念一遍
- 排序不对就只能改 prompt:「更看重架构能力」加一句,200 份简历全部重跑,技术深度那批人的排名也跟着乱掉。我说不清是措辞起了作用,还是模型这次状态不同
- Senior IC 岗和 Engineering Manager 岗要的是两种人,我写了两套 prompt 各跑一遍——同一个候选人在两张榜上差出十几名,两套分数还没有任何可比性
什么时候用这一招
当你需要:
- 按多个标准给一批东西排序、取前 X 名:简历筛选、工单优先级、内容质量分级、搜索结果重排
- 排序标准经常变,或者不同岗位、不同场景各有侧重:换权重就换榜,不用重新发请求
- 总分能拆开解释:每个维度多少分、权重占多少,全部摊在代码里
而且不想:
- 解析 LLM 输出的「综合评分」文本,还为格式漂移写一堆兜底代码
- 每调一次标准就改 prompt、全量重跑,结果还不可复现
- 同一批候选人为每个角色各跑一遍模型
🎒 开始前的准备
- 读过 高效工作流:四种架构模式,知道组合打分在四种官方模式里的位置(收益标注:成本、可靠、速度)
- 会写 Score 打分题:本课整课都建立在它之上——量表怎么写、分数为什么能落在两级之间,那课第 1、3 步讲过;第 5 步的三题优先级示例是本课的最小版
- 跑通过第一个请求:
.venv里装好了typesafe-sdk,TYPESAFE_API_KEY已在环境变量里 - 记得并行提问的结论:多个问题对同一 state 并行、独立评估,加题几乎不增加响应时间(本课 4 道题一次拿回就靠它)
核心思路
先拆,后合。 官方对 Composite scoring 的定义原话:
Break a complex judgment into atomic scores, combine with weights you control in code. (把一个复杂判断拆成原子分数,用你在代码里控制的权重合并。)
什么时候会用到它?官方的引子:我们经常要按好几个标准给一批东西排序。组合打分就是这件事的最简拆法——判断拆成独立维度,每维度单独打分,代码里加权合并。
官方模式页用简历筛选做完整示范(工程岗招聘,最终取前 X 名进入人工复筛),整条流水线长这样:
candidate resume(候选人简历)
│ 一次请求:简历 + 4 个 Score 问题
▼
┌──────────────────────────────────────┐
│ python_depth (Python 深度) │ ← 4 道题并行、独立评估
│ team_leadership (带团队经验) │
│ system_design (系统设计) │
│ generalist (多面手程度) │
└──────────────────────────────────────┘
│ 一次响应:4 个 0–4 的原子分数
▼
归一化到 0–1(每题 ÷ 4,在你的代码里完成)
├─ Senior IC 权重:40% Python + 10% 带团队 + 40% 设计 + 10% 多面手 → ic_score
└─ EM 权重: 15% Python + 40% 带团队 + 20% 设计 + 25% 多面手 → em_score
▼
按总分排名,每个角色各取前 X 名为什么不用一道大题问 LLM「这人整体多好」?
| 对比维度 | LLM prompt 综合打分 | Jev 组合打分 |
|---|---|---|
| 返回格式 | 一段小作文带个 7.5,格式随缘 | 每维度一个类型化 score(0–4)+ probabilities + confidence |
| 多标准 | 全部挤进一个综合分 | 每个标准一道题、一个独立分数 |
| 调排序标准 | 改 prompt 措辞、全量重跑、不可复现 | 改代码里的权重常量、立刻重算 |
| 多角色排序 | 每个角色一套 prompt 再跑一遍 | 同一批原子分数,换权重就出新榜 |
| 可解释性 | 「模型觉得好」 | 每维度分数 + 权重全在代码里可见 |
官方 how-to 手册把「拆问题」列为整本指南最重要的概念:宽泛的问题把好几个判断藏在一个答案后面;原子问题把判断暴露出来,你才能检查、调优、在代码里合并(docs/concepts/how-to-build-with-system-one.md:108)。
和前两课的关系:并行提问是传输层——4 道题靠一次请求拿回来;智能路由的门控回答「这个答案敢不敢用」;组合打分回答「这一批东西怎么排」。官方模式表里它的收益标注是 Cost, Reliability, Speed——四种模式里唯一把这三项占齐的(docs/patterns.md:19)。
跟我做
第 1 步:把「好候选人」拆成四个原子 Score 问题
为什么:「这位候选人好不好」是好几个判断叠在一起:Python 功底、带团队、系统设计、涉猎广度。挤在一道题里,你没法知道是哪个维度拉低了分,也没法只调一个维度。拆开,是后面一切的前提。
这是官方模式页 Playground 里给出的四个问题定义(从渲染页提取,instructions 与 criteria 均为官方原文):
from typesafe_sdk import Score
RESUME_QUESTIONS = {
"python_depth": Score(
instructions="How much depth of python experience does this candidate have, based on the supplied resume?",
criteria=[
"No Python experience mentioned",
"Mentioned but no detail",
"Used in projects, some specifics",
"Primary language, multiple projects",
"Deep expertise: architecture, performance, libraries",
],
),
"team_leadership": Score(
instructions="How much experience does this candidate have managing or leading engineering teams?",
criteria=[
"No management experience mentioned",
"Informal mentorship or tech lead role",
"Led a small team or project",
"Managed a team with direct reports",
"Managed multiple teams or an engineering org",
],
),
"system_design": Score(
instructions="How much experience does this candidate have designing large-scale or distributed systems?",
criteria=[
"No architecture work mentioned",
"Contributed to design discussions",
"Designed components of a larger system",
"Owned architecture of a significant system",
"Designed systems at scale across multiple domains",
],
),
"generalist": Score(
instructions="How much evidence is there that this candidate picks up unfamiliar tools, roles, or domains outside their core specialty?",
criteria=[
"Only one domain or role mentioned",
"Some variety but within a narrow field",
"Worked across a few different areas or tech stacks",
"Regularly moved between domains, wore many hats",
"Track record of ramping up in unfamiliar areas and delivering",
],
),
}三个细节:
- 一题一维度。 「一道题测好几件事」是 Score 课点名的坑:等级描述一旦写成「守时、聪明、有经验」,一项高一项低的输入就没位置放,confidence 掉下来、分数含义变浑。四道题拆干净,每道只管一件事
- 每级是情境,不是程度。 「Primary language, multiple projects」能拿去对照简历,「掌握较好」不能——模型只拿描述去对照 state,等级编号和邻居它都看不见。Score 课做过实验:纯数字量表打出 confidence 0.33,情境描述量表打出 1.0
- 级数 = 你能描述清楚的档位数。 官方这里每题 5 级;Score 课的原则是能描述多少级就用多少级,最多 10 级,3 级也行——不要加描述不清楚的级
你应该看到:RESUME_QUESTIONS 里四道 Score,每道只测一个维度、各带 5 级情境量表——Python 深度、带团队、系统设计、多面手,互不掺和。问题 ID(python_depth 这些)是你自己取的,不会发给模型,答案按同一个 ID 返回。
- 我的每道题只测一个维度,每级都是能对照的情境描述
第 2 步:一次请求并行拿回四个原子分数
为什么:四个维度互不依赖——上一课讲过,独立问题就该并进一次请求:并行评估,几乎不增加响应时间,只多花一点问题 token。四道题拆开却不多付四次请求的代价,这是组合打分「成本」收益的来源。
新建 resume_screen.py,接上第 1 步的问题定义,发一份简历试试:
from typesafe_sdk import TypeSafeClient
# RESUME_QUESTIONS 的定义见第 1 步,此处省略
resume = """
Jane Doe — Backend Engineer, 8 years
- Primary language Python; built and own the retry and backoff layer
of a 40-node distributed ingestion pipeline (2M events/day)
- Tech lead of a 4-person payments squad for 2 years; no direct reports
- Contributed to architecture reviews across the org
- Backend infrastructure only; one year on an internal CLI tool
"""
with TypeSafeClient() as client:
response = client.system_one(
state=resume,
questions=RESUME_QUESTIONS,
)
for qid in ["python_depth", "team_leadership", "system_design", "generalist"]:
answer = response.answers[qid]
print(qid, answer.score, answer.confidence)运行它:
.venv/bin/python resume_screen.py(官方 Playground 示例只包含 4 个问题、没有给简历 state,这份简历文本是本课自拟的测试输入。)
你应该看到:四个 0–4 之间的数,各带一个 0–1 的 confidence。对着这份自拟简历读:Python 是主语言且多处项目 → python_depth 偏高;tech lead 但无 direct reports → team_leadership 落在中间;只参与过架构讨论 → system_design 中低;几乎只在后端 → generalist 偏低。分数可以落在两级之间(比如 1.5)——它是概率加权的平均位置,验算方法在 Score 课第 3 步。具体数值以你的实际返回为准,随 Jev 版本可能变化。
关键观察:四个维度各自独立——一个候选人完全可以 Python 3.8、带团队 0.5。综合题里这两个信号会互相稀释,拆开之后各自清清楚楚。
💡 中文简历也能打,但先校准
官方说明英语是主要训练语言、准确度最好,包括中文在内的其他语言能用但准确度较低(docs/models.md)。中文简历上线前,先拿一批人工评过级的简历跑一遍,重点看各维度 confidence 的分布——哪一级在中文上分不开,就给那一级补情境描述。
- 清晰简历四个维度都拿到分;换一份侧重点不同的简历,各维度分数独立变化
第 3 步:写出 Jev 组合打分的加权合并代码
为什么:原子分数到手,合并规则必须写在代码里而不是 prompt 里——这是整个模式可解释性的来源。总分怎么算出来的,每一项摊开可见。
官方模式页的 scoring.py 原样如下:
py = response.answers["python_depth"].score / 4
lead = response.answers["team_leadership"].score / 4
arch = response.answers["system_design"].score / 4
general = response.answers["generalist"].score / 4
# Senior IC
ic_score = (0.40 * py) + (0.10 * lead) + (0.40 * arch) + (0.10 * general)
# Engineering Manager
em_score = (0.15 * py) + (0.40 * lead) + (0.20 * arch) + (0.25 * general)三个读法:
/ 4是归一化。 每道题 5 级,最高级号是 4,除完全部落在 0–1。一般式是「除以最高级号len(criteria) - 1」——Score 课第 5 步的normalized()就是这个一般式。本课四道题同级,看着可以偷懒不除,官方照样除——统一写归一化,日后某个维度的量表变长变短,合并代码不用跟着改- 权重就是产品表态。 Senior IC:Python 深度和系统设计各 0.40,带团队只占 0.10;Engineering Manager:带团队 0.40 一家独大。两套权重的和都是 1.00,总分因此仍落在 0–1,两张榜之间可以直接比
| 维度 | Senior IC | Engineering Manager |
|---|---|---|
| python_depth | 0.40 | 0.15 |
| team_leadership | 0.10 | 0.40 |
| system_design | 0.40 | 0.20 |
| generalist | 0.10 | 0.25 |
- 先归一化再加权,顺序不能反。 权重的含义建立在 0–1 尺度上:0.40 的 Python 恰好是 0.20 系统设计的两倍——分数没归一化时,这句话不成立
你应该看到:ic_score 和 em_score 各是一个 0–1 之间的数。对第 2 步那份简历,IC 总分应该高于 EM 总分——这份简历强在 Python、弱在带团队,而带团队在 EM 权重里一家占 0.40。
- 我的合并代码先归一化(÷ 最高级号)再乘权重,两套权重各自加起来是 1.00
第 4 步:同一批原子分数,用两套权重排两张榜
为什么:官方这个例子最漂亮的地方在「一次打分,多角色复用」:四个维度的分数对任何岗位都成立,变的只是权重。把权重收进字典、合并写成函数,你就有了一个能对任意角色排序的工具——而原子分数每个候选人只取一次。
WEIGHTS = {
"senior_ic": {
"python_depth": 0.40, "team_leadership": 0.10,
"system_design": 0.40, "generalist": 0.10,
},
"eng_manager": {
"python_depth": 0.15, "team_leadership": 0.40,
"system_design": 0.20, "generalist": 0.25,
},
}
def composite(answers, weights: dict) -> float:
"""归一化后按权重合并;算式与官方 scoring.py 一致。"""
total = 0.0
for question_id, weight in weights.items():
top_level = len(RESUME_QUESTIONS[question_id].criteria) - 1
total += weight * answers[question_id].score / top_level
return total
def fetch_scores(resumes: dict) -> dict:
"""每个候选人一次请求,拿回四个原子分数。"""
raw = {}
with TypeSafeClient() as client:
for name, resume in resumes.items():
response = client.system_one(state=resume, questions=RESUME_QUESTIONS)
raw[name] = response.answers
return raw
CANDIDATES = {
"jane": resume, # 第 2 步那份简历;再放两三份不同侧重的简历
# "li": ...,
# "sam": ...,
}
raw = fetch_scores(CANDIDATES)
ic_rank = sorted(raw, key=lambda n: composite(raw[n], WEIGHTS["senior_ic"]), reverse=True)
em_rank = sorted(raw, key=lambda n: composite(raw[n], WEIGHTS["eng_manager"]), reverse=True)
print("Senior IC 榜:", ic_rank)
print("EM 榜:", em_rank)(合并算式与官方 scoring.py 逐项一致;fetch_scores 循环和排序是本课补的普通 Python。每个候选人一次请求、4 道题并行——和官方 RAG cookbook「每段一次请求」同一个节奏。)
你应该看到:两张榜来自同一批原子分数——fetch_scores 只跑了一遍,EM 这第二个角色没有产生任何新的 API 调用。如果某位候选人 Python 很深但没带过人,他会出现在 IC 榜前列、EM 榜后排;带团队经验厚实的候选人正相反。这正是两套 prompt 各跑一遍永远给不了你的:两张榜基于同一组测量,差异只来自权重。
- 我的第二个角色(EM)没有触发第二次请求——分数取自同一批响应
第 5 步:排名不合预期时只改权重,不动 prompt
为什么:官方对这套代码的总结有两层:它让你能按总分排名,「但更重要的是,它让你看得见总分到底是怎么算出来的」;当排名最高的候选人不符预期,「你可以调整权重来找到合适的平衡」。权重是你的——这句原话(The weights are yours)在官方 primitives 文档和 Score 文档里各出现一次:组合结果和你团队的标准不一致时,改代码里的权重、重跑。
调试场景演一遍。用人经理看了 IC 榜:「带过团队的人被排得太低了。」
# 改权重前:python 0.40 / lead 0.10 / arch 0.40 / general 0.10
WEIGHTS["senior_ic"] = {
"python_depth": 0.35, "team_leadership": 0.20,
"system_design": 0.35, "generalist": 0.10,
}
# 原子分数已经在 raw 里,直接重算——不用再发任何请求
ic_rank = sorted(raw, key=lambda n: composite(raw[n], WEIGHTS["senior_ic"]), reverse=True)三种调整,按代价从低到高试:
| 调整 | 代价 | 影响范围 |
|---|---|---|
| 改权重常量 | 零 API 调用(分数已到手,本地重算) | 只影响这一张榜 |
| 加一道维度(如沟通能力) | 每个候选人重跑一次请求——但每个候选人仍然只是一次请求,官方原话「The call count stays at one」 | 所有用这套分数的榜共用新维度 |
| 改量表措辞(criteria) | 全部候选人重跑,新旧分数不可比 | 所有已有分数作废 |
你应该看到:改完权重,ic_rank 立刻重算出一张新榜——raw 里一个数都没变,因为问题一个字没动。这就是「改系数不改 prompt」:可复现、可审计、可回滚(把权重改回去,榜就回去了)。
- 我把「改权重」当成排名调试的第一手段,改量表措辞放在最后
检查点 ✅
- 我能说出 Jev 组合打分的两步:每维度一道原子 Score 一次请求并行拿分;代码里归一化后加权合并
- 我知道为什么先归一化:不同量表的满分不同(三级满分 2、五级满分 4),除以最高级号
len(criteria) - 1才在同一尺度,权重才表达它字面的意思 - 我的权重是代码常量、和为 1.00;排名不合预期先改权重本地重算,不发新请求
- 同一批原子分数我排出了 IC、EM 两张榜,第二个角色零额外 API 调用
- 排序时我连各维度的 confidence 一起看:低置信的候选人标记复核,而不是硬排
踩坑提醒
❌ 一道题问「整体好不好」
prompt 写「请综合评估这位候选人,打 1–10 分」,好几个判断挤在一个答案里。官方 how-to 手册把这列为整本指南最重要的概念:
宽泛的问题把好几个判断藏在一个答案后面;原子问题把这些判断暴露出来,你才能检查、调优、在代码里合并。
一道综合题你没法知道是哪个维度拉低了分,也没法只调一个维度。正确做法:一个维度一道 Score——本课第 1 步的四道题就是范式。
❌ 不归一化直接加权
三级量表满分 2、五级满分 4——不归一化,级数多的维度天然占便宜,你写的 0.40 实际起的作用可能远大于 0.40。官方在 Score 文档里专门强调:先除以最高级号再合并,「权重才表达它字面的意思」(Then the weights mean what they say)——0.6 的 severity 恰好是 0.3 的 frustration 的两倍。本课四道题同级,/ 4 看似多余,官方照样写——防的就是日后量表变长变短。
❌ 排名不对就去改量表措辞
量表一改,分数的含义就变了:所有候选人的旧分数全部作废,得全量重跑,新旧榜还不能直接比。官方给的动作是「改代码里的权重、重跑」——零成本、立刻可验、只影响一张榜(第 5 步的代价表)。措辞是最后手段:只有当某个维度的 confidence 系统性偏低(等级在你的真实数据上分不开)时才回头改量表,并按 Score 课的方法用已知样本验证新量表。
❌ 只看总分,不看各维度的 confidence
总分把四个维度压成一个数,低置信维度的分数混在里面看不出来。某位候选人 system_design 的 confidence 只有 0.3(等级重叠,或简历信息不够),他的总分照样算得出来——但拿这个总分决定他进不进面试,依据是不可靠的。官方对 confidence 的三档用法同样适用于排序:高 → 直接采用;中 → 标记复核;低 → 不让这个维度参与决策、转人工。三档的完整解读在 Confidence 置信度课。
进阶技巧(可选)
加权合并不只用于 Score。 官方 how-to 手册第 7 节的示例,用三道 Noul 的概率加权合成一个质量分——Noul 本来就是 0–1 的概率,连归一化都省了:
answers = response.answers
# Combine independent signals into one application-specific score.
quality = (
0.4 * answers["answers_request"].noul
+ 0.4 * answers["citations_are_supported"].noul
+ 0.2 * (1 - answers["contradicts_context"].noul)
)(官方原样代码,docs/concepts/how-to-build-with-system-one.md。注意第三个信号取 1 - noul:与上下文矛盾的概率越低,质量分越高。)
让机器学权重。 权重不必永远手调。官方同一段文档给了进阶路径:把各题的 probabilities 当特征,喂给下游的经典机器学习模型去学组合方式;没有标签时,可以用一组昂贵的推理模型当 ensemble 生成标签。官方 AutoResearch cookbook(autoresearch_feature_discovery,暂未单独成页)演示了完整做法:autoresearch 循环提议 TypeSafe 问题,把自由文本转成数值特征,训练一个 CatBoost 回归器,再用模型误差反过来改进问题。
真实场景里的信号组合。 「多个信号、代码里合并」的思路在实战课里到处都是:
- RAG 段落把关:每个检索段落问 4 道 Noul(相关吗、含证据吗、与前提矛盾吗、有注入吗),代码决定哪些进 answering model 的上下文。官方特意点明:四道题没有一道在问「要不要收录这段」——那个决定在代码里,改标准就是改一个数字,不用改写问题
- 搜索与匹配:重排是纯排序形态——每个查询-候选对一道 Noul,按概率排序,官方实测 top-1 命中 5%→18%;实体对齐则是 1 道 Score 加 3 道 Noul 的组合判断
本课小结
✅ Jev 组合打分 = 官方 Composite scoring 模式:复杂判断拆成每维度一道原子 Score,一次请求并行拿回,代码里归一化、加权合并——官方收益标注 Cost, Reliability, Speed,四种模式里唯一占齐这三项的。
✅ 权重即产品决策:Senior IC 0.40/0.10/0.40/0.10,Engineering Manager 0.15/0.40/0.20/0.25——同一批原子分数,两套权重,两张榜,第二个角色零额外请求。
✅ 可解释是最大收益:总分怎么算出来的,每个维度的分数和权重全在代码里可见;排名不合预期,先改权重本地重算,可复现、可回滚。
✅ 加维度不加请求:问题字典添一道题,每个候选人的调用次数仍是 1——官方原话「The call count stays at one」。
关键事实速查:
| 事项 | 值 | 出处 |
|---|---|---|
| 模式一句话 | Combine several dimensions of analysis into a single score | docs/patterns.md |
| 模式定义 | Break a complex judgment into atomic scores, combine with weights you control in code | docs/patterns/composite-scoring.md |
| 四道题 | python_depth / team_leadership / system_design / generalist,各 5 级量表 | 官方 Playground(patterns/composite-scoring,从渲染页提取) |
| 归一化 | score ÷(len(criteria) − 1),本例每题 ÷ 4 | docs/primitives/score.md |
| IC 权重 | Python 0.40 / 带团队 0.10 / 设计 0.40 / 多面手 0.10 | docs/patterns/composite-scoring.md(scoring.py) |
| EM 权重 | Python 0.15 / 带团队 0.40 / 设计 0.20 / 多面手 0.25 | 同上 |
| 收益 | Cost, Reliability, Speed | docs/patterns.md |
组合打分是「答案怎么用」层的模式,上线前同样要过一遍 Jev 1.13 踩坑手册的九种失败模式——尤其「字面理解」一条:量表描述写精确条件,别写「有较好的架构能力」这种程度词。
下一课预告
四种官方模式到这课全部过了一遍(扇出、意图路由、置信度门控、组合打分),剩最后一个问题:这些调用 Jev 的代码谁来写。下一课 与 Claude Code 配合开发:TypeSafe Agent Skill。
你会学到:
- Jev 不是 coding agent 的 LLM:不能替换 Claude Code / Cursor 背后的模型,正确姿势是让 agent 写「调用 Jev 的代码」
- 两条命令装好官方 Agent Skill:
claude plugin marketplace add typesafe-ai/skills,再claude plugin install typesafe@typesafe-ai - 3 个官方示例 prompt:头脑风暴、跑便宜实验、找相似 cookbook
- 5 个常见问题的排查方法:agent 不用 skill、路由不符预期、到处设 confidence 阈值、代码难 review、agent 编造字段