Choice
当答案是固定集合中的一个时,用 Choice。例如:工单归哪个团队、商品属于哪个类目、一段代码用的是什么语言。
Choice 的答案是 choice 中的选中项,同时返回每个选项的概率 probabilities 和一个 confidence。
几个典型问题:
"这段代码是用什么语言写的"
→ python / javascript / typescript / go / rust / 其他
"这次会议属于哪种类型(根据标题和议程)"
→ 站会 / 迭代规划 / 复盘 / 一对一 / 头脑风暴 / 都不是
"这个商品属于哪个一级类目"
→ 数码家电 / 服饰鞋包 / 家居日用 / 食品生鲜请求结构
请求体顶层有三个字段:state(要评估的内容)、model(可省略,默认 xiangxin-latest)和 questions(问题 ID → 问题对象)。每个 Choice 问题包含:
type:固定为"choice";instructions:模型要回答的问题;criteria:选项映射,键是选项名,值是这个选项的描述(不需要描述时写null)。
下面的 state 是一家运动鞋网店收到的工单,问题是该转给哪个团队:
{
"state": "跑鞋收到了,但尺码发错了:我下单的是 42 码,寄来的是 40 码,能换吗?",
"model": "xiangxin-latest",
"questions": {
"department": {
"type": "choice",
"instructions": "这张工单应该由哪个团队处理?",
"criteria": {
"returns": "换货、发错货、商品破损",
"shipping": "物流进度、延误、丢件",
"billing": "扣款、发票、支付异常"
}
}
}
}department 是你起的 ID,答案以同样的 ID 返回,模型看不到它。但选项名和描述都会发给模型,所以描述要写得能把各个选项区分开。
用 Python SDK,同一个问题写成 Choice:
from xiangxin import Choice, XiangxinClient
with XiangxinClient() as client:
resp = client.system_one(
state="跑鞋收到了,但尺码发错了:我下单的是 42 码,寄来的是 40 码,能换吗?",
questions={
"department": Choice(
instructions="这张工单应该由哪个团队处理?",
criteria={
"returns": "换货、发错货、商品破损",
"shipping": "物流进度、延误、丢件",
"billing": "扣款、发票、支付异常",
},
),
},
)
print(resp.answers["department"].choice) # returns说明
instructions 和 criteria 里的每个值都可以是字符串、对象或数组。先从字符串开始;当一个选项需要多种说明(覆盖什么、不覆盖什么、举几个例子)时,再改用对象,见下文 结构化的 instructions 与 criteria 与 进阶:结构化。
响应结构
每个问题在 answers 里对应一项。上面请求的响应:
{
"model": "xiangxin-1.0.0",
"answers": {
"department": {
"type": "choice",
"choice": "returns",
"confidence": 0.98,
"probabilities": {"returns": 0.99, "shipping": 0.01, "billing": 0.0}
}
},
"usage": {"input_tokens": 97, "output_tokens": 41}
}除 type 外,Choice 答案有三个值:
choice:概率最高的选项;probabilities:所有选项上的完整分布,两位小数,总和为 1;confidence:由分布形状算出的 0–1 之间的数。概率集中在一个选项上,置信度高;分散在几个选项上,置信度低。计算公式为(n × 最高概率 − 1) / (n − 1),其中 n 是选项个数。本例为(3 × 0.99 − 1) / 2 ≈ 0.98(按取整前的概率计算,所以末位可能差 0.01)。
这张工单很清楚,几乎全部概率都在 returns 上。如果工单同时提到"尺码不对"和"被扣了两次钱",概率会在 returns 和 billing 之间分开,置信度随之下降。
选项名会被模型读到
选项的键名本身也是模型输入的一部分,而不只是描述。这带来两个实际后果:
- 给选项起有意义的名字。
returns比opt_1好,退换货也可以。键名和描述应当说的是同一件事;如果键名叫billing而描述写的是物流,模型会被两边拉扯。 - 别用两个不同的键名表示同一件事。 如果把同一个描述挂在两个键下面,模型对它们的打分并不会平分,而是受键名影响。
注意 选项难以区分时存在"首位偏好"
当多个选项的名字和描述几乎一样、模型无从区分时,概率会偏向列表中靠前的选项。这不是模型"认为"第一个更对,而是它没有依据时的倾向。解决办法是让每个选项都可区分:写清楚它和相邻选项的边界。如果你的业务确实存在大量近似选项,先在代码里去重或合并。更多说明见 已知短板。
把选项列全,并留一个"其他"
一个 Choice 最多支持 255 个选项,每个选项只多几个 token。所以与其给模型一个"精选短名单",不如把所有团队、类目或商品都列上。
如果列表可能覆盖不到所有输入,加一个 other(或"都不是")选项,并写清楚它的含义,比如"以上类别都不符合"。否则模型只能在给定选项里硬选一个,而这个硬选出来的答案可能置信度还不低。
对于很深的类目树(比如电商的"一级类目 → 二级 → 三级"),可以逐层问 Choice:先选一级类目,再用选中节点的子类目作为下一题的选项。这属于"第二个请求依赖第一个答案"的合理情形。
一个更复杂的例子
真实的客服系统往往不止要知道"转给谁",还想知道退货原因、物流问题类型、顾客想要什么、情绪如何。下面这张工单涉及三个团队,而且顾客没说清楚想要什么:
from xiangxin import Choice, XiangxinClient
TRIAGE_QUESTIONS = {
"department": Choice(
instructions="这张工单应该由哪个团队处理?",
criteria={
"returns": "换货、发错货、商品破损",
"shipping": "物流进度、延误、丢件",
"billing": "扣款、发票、支付异常",
},
),
"return_reason": Choice(
instructions="如果顾客想退换货,原因是什么?",
criteria={
"wrong_size": "尺码不合适",
"wrong_item": "发来的是别的商品",
"damaged": "商品到手已损坏或有质量问题",
"changed_mind": "商品没问题,只是不想要了",
"other": "以上原因都不符合",
},
),
"shipping_issue": Choice(
instructions="如果是物流问题,属于哪一种?",
criteria={
"not_delivered": "包裹一直没到",
"delayed": "包裹晚到,但仍在路上或已送达",
"wrong_address": "送错了地址",
"damaged_in_transit": "运输途中损坏",
"other": "以上物流问题都不符合",
},
),
"requested_resolution": Choice(
instructions="顾客希望怎么解决?",
criteria={
"exchange": "换成另一件商品或另一个尺码",
"refund": "退钱",
"replacement": "同款商品重新发一件",
"information": "只想得到答复,不需要操作",
},
),
"tone": Choice(
instructions="顾客的语气如何?",
criteria={"calm": None, "frustrated": None, "angry": None},
),
}
ticket = "鞋子晚了两周才到,尺码还发错了。另外我信用卡上被扣了两笔 699 元。你们打算怎么处理?"
with XiangxinClient() as client:
resp = client.system_one(state=ticket, questions=TRIAGE_QUESTIONS)其中 return_reason 和 shipping_issue 是投机式问题:前者只在转给退换货团队时有用,后者只在物流团队时有用。tone 的三个选项名本身就很清楚,所以描述写 None(JSON 中为 null)。
响应:
{
"model": "xiangxin-1.0.0",
"answers": {
"department": {
"type": "choice", "choice": "billing", "confidence": 0.33,
"probabilities": {"returns": 0.24, "shipping": 0.21, "billing": 0.55}
},
"return_reason": {
"type": "choice", "choice": "wrong_size", "confidence": 0.47,
"probabilities": {"wrong_size": 0.58, "wrong_item": 0.05, "damaged": 0.06, "changed_mind": 0.02, "other": 0.29}
},
"shipping_issue": {
"type": "choice", "choice": "delayed", "confidence": 0.7,
"probabilities": {"not_delivered": 0.03, "delayed": 0.76, "wrong_address": 0.02, "damaged_in_transit": 0.02, "other": 0.17}
},
"requested_resolution": {
"type": "choice", "choice": "replacement", "confidence": 0.11,
"probabilities": {"exchange": 0.23, "refund": 0.24, "replacement": 0.33, "information": 0.2}
},
"tone": {
"type": "choice", "choice": "frustrated", "confidence": 0.25,
"probabilities": {"calm": 0.09, "frustrated": 0.5, "angry": 0.41}
}
},
"usage": {"input_tokens": 367, "output_tokens": 266}
}逐个来看:
department选了billing(0.55),重复扣款是最需要先处理的事;但returns(0.24)和shipping(0.21)也分到了不少概率。这张工单本来就横跨三个团队,0.33 的置信度如实反映了这一点。return_reason偏向wrong_size(0.58),other也有 0.29,置信度 0.47。它是投机问题,主团队不是退换货时代码不读它。shipping_issue是delayed(0.76),置信度 0.7,"晚了两周才到"写得很明白。它同样是投机问题。requested_resolution四个选项几乎摊平,replacement以 0.33 勉强领先,置信度只有 0.11。重复扣款指向"退钱",尺码不对又指向"换货",而顾客自己没说想要什么。tone在frustrated(0.5)和angry(0.41)之间,置信度 0.25。
接下来代码只读需要的答案,并把低置信度当作"先问清楚再动手"的信号:
CC_THRESHOLD = 0.2 # 次要团队概率超过它就抄送
ASK_BELOW = 0.5 # 诉求置信度低于它就先问顾客
def route(resp):
a = resp.answers
dept = a["department"]
actions = {"assign_to": dept.choice, "cc": [], "ask_customer": False}
# 次要团队概率足够高时抄送一份
for team, p in dept.probabilities.items():
if team != dept.choice and p >= CC_THRESHOLD:
actions["cc"].append(team)
# 只读与主团队相关的投机问题
if dept.choice == "returns":
actions["issue"] = a["return_reason"].choice
elif dept.choice == "shipping":
actions["issue"] = a["shipping_issue"].choice
# 顾客诉求不明确时,先追问,不要替顾客做决定
if a["requested_resolution"].confidence < ASK_BELOW:
actions["ask_customer"] = True
actions["priority"] = "high" if a["tone"].choice == "angry" else "normal"
return actions对上面这张工单:分给账务团队;returns 的 0.24 和 shipping 的 0.21 都超过 0.2,抄送退换货和物流团队;诉求置信度 0.11 低于 0.5,先询问顾客想退款、换货还是补发。
一次请求、五个答案,路由逻辑全是普通的 if。以后如果还想知道顾客用的是哪种语言、提到了哪件商品,往 TRIAGE_QUESTIONS 里再加一题即可,请求数仍然是一次。完整的工单分流案例见 电商客服工单分流。
按概率排序,而不仅是取最高
choice 只是 probabilities 的 argmax。很多场景下完整分布更有用:
- 取前 K 个候选:例如从 200 个标准话术中挑出最相关的 3 条,交给人工或下游大模型再确认;
- 多标签近似:概率超过某个阈值的次要选项也值得处理(上例的抄送);
- 与其他信号相乘:把 Choice 的概率当作特征,和业务规则打分组合。
top3 = sorted(
resp.answers["department"].probabilities.items(),
key=lambda kv: kv[1],
reverse=True,
)[:3]注意 Choice 的概率是相对的:它回答的是"这些选项里哪个最合适",即使所有选项都不太合适,概率之和仍然是 1。如果你还需要知道"到底有没有合适的",要么加一个"都不是"选项,要么另问一个 Noul。
结构化的 instructions 与 criteria
先给每个选项写一句话描述。当两个选项很相近、模型总是混淆时,把描述改成对象:写明这个选项包括什么、不包括什么(应归到哪个邻居),再给几个真实输入的例子。
下面 return_policy 和 return_status 很容易混:两类工单都会提到"退货""退款"。所以每个选项都写明自己不负责什么:
{
"state": "我上周三寄回的那双鞋,快递显示已签收,退款什么时候到?",
"model": "xiangxin-latest",
"questions": {
"intent": {
"type": "choice",
"instructions": "顾客在问什么?",
"criteria": {
"return_policy": {
"what": "询问退货规则:能不能退、多少天内、要不要吊牌、运费谁出",
"not_for": "已经寄回、在问进度的,归 return_status",
"examples": ["拆封了还能退吗?", "七天无理由包括运费吗?"]
},
"return_status": {
"what": "已经发起或寄回退货,询问审核、签收、退款到账进度",
"not_for": "还没退、只是问规则的,归 return_policy",
"examples": ["退货单审核通过了吗?", "寄回去五天了钱还没到"]
},
"other": "与退货无关的问题"
}
}
}
}what、not_for、examples 这些字段名不是 API 的一部分,也没有保留字,由你自己决定。模型会连同字段名一起读到,所以用简短、能说明后面内容的名字即可。更多写法见 进阶:结构化。
常见陷阱
| 现象 | 可能的原因 | 改法 |
|---|---|---|
| 两个选项总是各占一半 | 选项之间有重叠,或输入确实同时属于两者 | 写清边界(not_for);若真的可以同时属于,改成每个选项一个 Noul |
| 所有输入都偏向第一个选项 | 选项之间难以区分,触发首位偏好 | 让每个选项的描述真正不同;去掉重复选项 |
| 明显不属于任何选项的输入也给了高置信度 | 没有"其他"出口 | 加 other 选项,或另问一个 Noul"是否属于以上任一类" |
| 结果随键名改动而变化 | 键名也是输入 | 用语义清晰、与描述一致的键名 |
| 一题里塞了两个维度(如"部门 + 紧急程度"组合成 9 个选项) | 维度混杂 | 拆成两个 Choice,在代码里组合 |

