推测式扇出
推测式扇出(Speculative fan-out)的做法很简单:针对同一份状态,把你的程序可能用到的所有问题放进一次请求里,包括那些只有在某些分支下才有意义的问题。答案回来后,由代码决定哪些有用、哪些丢弃。
之所以能这样做,是因为象信一号处理请求的方式:状态只读一遍,所有问题在它之上并行、彼此隔离地求值。多加一个问题,几乎不增加耗时,只多花该问题本身的那点 token。
一个例子:外卖平台投诉工单
假设你在做一个外卖平台的投诉处理系统。每张工单需要:
- 判断投诉类型(配送、餐品、支付、骑手态度……);
- 如果是餐品问题,还要知道是否涉及食品安全、是否附了照片证据;
- 如果是支付问题,还要知道用户是否要求退款;
- 无论什么类型,都想知道用户的情绪激烈程度。
直觉上的写法是"先分类,再根据类别追问",也就是两次甚至三次调用。推测式扇出的写法是:一次全问完。
┌──────────────── 一次请求:工单 + 6 个问题 ────────────────┐
工单 ───▶ │ category(Choice) food_safety(Noul) has_photo(Noul) │
│ refund_requested(Noul) anger(Score) compensation_hint(Noul) │
└──────────────────────────────┬─────────────────────────────┘
▼
代码:按 category 取用相关答案
┌──────────────┬────────────┴───────┬──────────────┐
餐品问题 支付问题 配送问题 其他
看 food_safety 看 refund_requested 看 anger 转人工
与 has_photo第一步:一次发出所有问题
from xiangxin import XiangxinClient, Choice, Noul, Score
client = XiangxinClient()
ticket = {
"order_id": "WM20260918-7731",
"merchant": "老街牛肉面(望京店)",
"user_message": (
"牛肉面里吃出一根头发,我已经拍照了。汤也是凉的,"
"等了一个多小时。这钱必须退,不然我去 12315 投诉。"
),
"attachments": ["IMG_2031.jpg"],
}
QUESTIONS = {
"category": Choice(
instructions="`user_message` 投诉的主要问题属于哪一类?",
criteria={
"food": "餐品本身的问题:异物、变质、做错、分量不足、口味",
"delivery": "配送问题:超时、送错地址、餐品洒漏",
"payment": "支付与账单问题:重复扣款、优惠未生效、退款未到账",
"rider": "骑手服务态度或行为问题",
"other": "以上都不是",
},
),
# 以下两个问题只在 category == "food" 时才有意义
"food_safety": Noul(
instructions="`user_message` 是否描述了可能危害健康的食品安全问题(如异物、变质、吃后不适)?",
),
"has_photo": Noul(
instructions="用户是否表示已拍照或已附上图片证据?",
),
# 只在 category == "payment" 时才有意义
"refund_requested": Noul(
instructions="用户是否明确要求退款或赔偿?",
),
# 与类型无关,始终有用
"anger": Score(
instructions="用户在 `user_message` 中表现出的情绪激烈程度",
criteria=[
"平静陈述事实",
"不满但克制",
"明显愤怒",
"极度愤怒,扬言投诉或曝光",
],
),
"external_complaint": Noul(
instructions="用户是否表示要向监管部门、媒体或社交平台投诉?",
),
}
resp = client.system_one(state=ticket, questions=QUESTIONS)
a = resp.answers第二步:由代码挑选有用的答案
def route(a) -> str:
category = a["category"].choice
if category == "food":
if a["food_safety"].noul > 0.7:
# 食品安全问题:无论用户是否拍照,都优先处理
return "food_safety_team"
return "merchant_quality"
if category == "payment":
if a["refund_requested"].noul > 0.6:
return "refund_queue"
return "billing_support"
if category == "delivery":
return "delivery_ops"
return "human_review"
queue = route(a)
# 情绪和外部投诉风险与类型无关,任何分支都参考
priority = "normal"
if a["anger"].score >= 2.5 or a["external_complaint"].noul > 0.7:
priority = "urgent"
print(queue, priority) # 示意输出:food_safety_team urgent这张工单被判为 food,于是 refund_requested 的答案被直接忽略——但如果下一张工单是支付问题,它就正好派上用场,而你没有为此多等一轮网络往返。
为什么划算
一次请求的成本和耗时主要由状态决定,而不是问题数量:
- 计费:按输入 token 计费。状态在一次请求里只算一次;每个问题只多出它自己的 instructions 和 criteria 的 token。
- 耗时:状态只被读取一次,问题在其上并行求值。多问几个问题,耗时变化通常很小。
把同样的问题拆成 N 次调用,状态就要被重复发送、重复读取 N 次。下面是一个示意对比(假设状态 1,500 token,每个问题约 60 token,共 6 个问题):
| 方式 | 输入 token(示意) | 费用(示意) | 网络往返 |
|---|---|---|---|
| 一次请求,6 个问题 | 约 1,500 + 6×60 ≈ 1,860 | 约 ¥0.000078 | 1 |
| 6 次请求,每次 1 个问题 | 约 6×(1,500 + 60) ≈ 9,360 | 约 ¥0.000393 | 6(串行时耗时成倍增加) |
| 先分类再追问(2 次) | 约 2×1,500 + 6×60 ≈ 3,360 | 约 ¥0.000141 | 2(第二次必须等第一次) |
说明
表中数字仅用于说明量级关系,按 ¥0.042/百万输入 token 估算。实际 token 数以响应中的 usage.input_tokens 为准。
状态越长、问题越多,扇出的优势就越明显。一份 8,000 token 的合同配 30 个检查项,拆开调用意味着把合同重复发送 30 遍。
问题互不干扰
同一请求中的问题彼此隔离:一个问题的答案不会成为另一个问题的上下文,也不会因为多加了别的问题而改变。这意味着:
- 你可以随时增删推测性问题,而不必担心已有问题的结果发生漂移;
- 即使加入一个"诱导性"的问题(例如"用户是否已经怒不可遏?"),也不会把其他问题的答案带偏。
当然,由于 GPU 数值计算的细微差异,同一请求重复发送时概率可能在小数点后第二位上有轻微波动,这与是否扇出无关,详见已知短板。
何时使用 / 何时不用
适合使用:
- 下游有分支逻辑,不同分支需要不同的判断;
- 同一份材料要从多个维度检查(合同审查、简历筛选、内容审核);
- 延迟敏感,不希望串行多轮请求。
不适合,或需要两次请求的情况:
- 第二个问题的状态必须依赖第一个答案才能构造。例如先判断用户问的是哪个订单,再去数据库拉取该订单详情作为新状态;
- 第二个问题的选项依赖第一个答案,例如层级分类中先选大类、再从该大类的子类里选;
- 问题数量多到把总 token 推近上限(单请求 64k,状态 + 最长问题 ≤ 32k,见模型)。
判断标准只有一个:如果第二批问题本可以对原始状态提出,就放进第一次请求。
常见陷阱
- 只因为"感觉更整洁"就拆成多次调用。 这是编程智能体尤其容易犯的习惯。智能体 Skill 会专门提醒它们合并问题。
- 在一个问题里塞多个判断。 扇出的意思是"多问几个小问题",而不是"把一个大问题写得更长"。"是否涉及食品安全并且用户有照片"应拆成两个 Noul,在代码里用
and组合。 - 忘了处理无关答案。 推测性问题在不相关的分支里依然会返回一个数值。不要在支付工单里读取
food_safety并据此行动——在代码里显式地只在对应分支中使用它。 - 依赖问题 ID 表达语义。 问题 ID 只给你的代码用,不会发送给模型。所有语义都要写进
instructions。

