分类是从闭合集合返回一个标签,本质是一个 token 而非十二个结构化 token。JSON 输出扔掉了概率分布,且输出 token 串行导致高延迟,单 token 分类在延迟上有数量级优势。
分类是 JSON 结构化输出中最不需要 JSON 的任务。你只需要从一个封闭集合中取一个值——也就是一个 token、一次前向传播,外加一个真正能用的概率分布。
常规做法要求模型返回 {"label":"refund_request"},格式严格按 schema 来。能用,但也会多生成十几个 output token,而实际上一个就够了——其中大部分还是结构性 token,真正有价值的东西——模型在选择标签时所依据的概率分布——反而被丢弃了。
在延迟方面差异毫不含糊,因为 output token 是串行生成的。十几个 output token 就要走十几个 forward pass。返回一个 token 只需要一次,而对于一个在支持队列中每条消息都要跑一次分类器的服务来说,这就是能把服务放进同步链路和不能放进去的差别。
成本方面没有初看那么戏剧化,值得坦诚说明。分类 prompt 通常是输入主导的——待分类的消息加上标签定义——所以把十二个 output token 砍成一个,账单变化只有几个百分点,而不是一个数量级。延迟才是真正的收益,而那个没人预料到的收益是:你可以设阈值的数字——这才是把分类器变成级联的关键。
将标签以编号列表呈现,要求返回编号,限制输出只有一个 token,然后读取 logprobs:
system: Classify the message. Answer with a single digit and nothing else.
1 = refund_request customer wants money back for a completed order
2 = shipping_delay customer asks where an order is
3 = product_defect item arrived broken or does not work
4 = account_access cannot log in, password, 2FA
5 = other none of the above
user: <the message>
params: max_tokens=1, temperature=0, logprobs=true, top_logprobs=20
返回的 content 是一个字符。真正有意思的是 logprobs.content[0].top_logprobs,它给出了该位置上概率最高的候选及其 log-probabilities。把属于你标签的那些取指数、再归一化使它们相加等于一,你就得到了一个后验分布——以模型给出的是一个标签为条件——这正是你想要的东西,而 JSON 响应给不了你。
import math, os
from openai import OpenAI
client = OpenAI(base_url=os.environ.get("BASE_URL"), api_key=os.environ["API_KEY"])
MODEL = os.environ["MODEL"]
LABELS = {"1": "refund_request", "2": "shipping_delay", "3": "product_defect",
"4": "account_access", "5": "other"}
SYSTEM = ("Classify the message. Answer with a single digit and nothing else.\n"
"1 = refund_request customer wants money back for a completed order\n"
"2 = shipping_delay customer asks where an order is\n"
"3 = product_defect item arrived broken or does not work\n"
"4 = account_access cannot log in, password, 2FA\n"
"5 = other none of the above")
def classify(text: str) -> dict:
r = client.chat.completions.create(
model=MODEL, max_tokens=1, temperature=0,
logprobs=True, top_logprobs=20,
messages=[{"role": "system", "content": SYSTEM},
{"role": "user", "content": text}],
)
top = r.choices[0].logprobs.content[0].top_logprobs
# Sum probability mass per label: " 1" and "1" are different tokens
# and both mean label 1.
mass = {name: 0.0 for name in LABELS.values()}
seen = 0.0
for cand in top:
key = cand.token.strip()
if key in LABELS:
p = math.exp(cand.logprob)
mass[LABELS[key]] += p
seen += p
if seen == 0.0: # no label in the top-k at all
raise OffDistribution(r.choices[0].message.content)
probs = {k: v / seen for k, v in mass.items()} # renormalise
ranked = sorted(probs.items(), key=lambda kv: -kv[1])
return {
"label": ranked[0][0],
"p": round(ranked[0][1], 4),
"margin": round(ranked[0][1] - ranked[1][1], 4), # the useful number
"probs": probs,
"unseen_mass": round(1 - seen, 4), # mass on non-label tokens
}
class OffDistribution(Exception): pass
margin 是应该关注的数字。单一最高概率会把"置信"和"只有一个合理选项"混为一谈;与第二名的差距才告诉你模型是否真的在两个选项之间权衡。把低 margin 的情况路由到更大的模型或人工,你就得到了一条级联,成本由你通过移动阈值来控制。
unseen_mass 是健康检查。它是模型放在非标签 token 上的概率。很小是正常的;很大意味着模型想说点别的,这通常说明你的标签集没有覆盖这个输入。
因为输出的是一个分布而非一个词,这个分类器的评估方式和普通分类器一样。取几百个带标签的样例打分,建立混淆矩阵,读它而不是 headline 准确率:几乎总有一到两个非对角线单元格占主导,它们指出了定义重叠的那对标签。修复那是 prompt 编辑,不是换模型。用同样的样例集扫一遍 margin 阈值,你就得到了升级量和错误量之间的权衡曲线——这是拿来给犯错成本负责人看的数字。
前导空白。大多数 BPE tokenizer 把 "1" 和 " 1" 当作不同 token。上面的 .strip() 不是在整理代码,是正确性修复,省略它是这个模式实现静默返回零的原因。
多 token 标签会彻底破坏它。refund_request 是好几个 token;不存在一个单一位置其分布恰好覆盖你的类。数字和单个大写字母是安全的。标签超过二十个左右时,用两阶段分类而不是用更长的标签。
top_logprobs 是截断的。你得到的是 top k 候选,不是整个词表。top k 之外的标签在这个方案里会得到概率零,而不是一个很小的值。五个标签加 k=20 时这很少是约束;十五个标签时就未必了。
不是每个端点都返回 logprobs。支持情况因 provider 和模型而异,有些返回 null 字段而不报错。在依赖它之前先检查。
标签顺序和标签名都很重要。它们在 prompt 里,所以是证据。不要以为数字是中立的——如果 5 = other 系统性地吸收了某个类,在对模型下结论之前先试试重新排序。
单 token 分类器放弃了两样东西。它不能返回多个标签,也不能在标签之外返回任何东西——没有提取的 span,没有理由,没有次要字段。如果这些有任何一样是你需要的,你就需要 JSON 响应,多出来的 token 是代价。
它也放弃了先导推理字段所提供的推理空间,这对于真正困难的判断很重要。经验法则:高容量路由——决策是表面判断——用单 token;低容量决策——做对比十个额外 token 更值得——用带推理字段的 JSON。两者都跑也没问题——先跑便宜的分类器,低 margin 时升级。
级联只有在第一阶段真正便宜时才值得,所以两个模型的价格比是整个设计的核心——每个模型的输入价格和输出价格共同决定了你选的 margin 阈值最终变成多少每月的账单。
Enums vs Free Text: Constraining the Answer Space
Confidence Scores in Structured Extraction
Function Calling vs Structured Output: Pick the Right One