文档目录
并行提问
基于 Wikipedia 上的 GDPR 词条运行一个 13 问题的合规简报,结果显示把所有问题批量放进一次 TypeSafe 调用既便宜 12.2 倍又快 10.0 倍,且答案完全一致。
非官方中文翻译 · 内容以 英文原文 ↗ 为准 · 同步于 2026-10-15
你有一份文档和关于它的 N 个问题。你可以发一个请求带上全部 N 个问题,也可以发 N 个请求,每个请求只带一个问题。使用 TypeSafe 时,两种方式得到的答案都一样:每个问题各自针对文档独立打分,所以其答案不依赖于请求里还有什么其他内容。
为了验证这一点,本实战示例(cookbook)在两种方式下都把每个问题问多次——一种是全部 N 个合在一个请求里,另一种是每个请求只问一个问题——并比较相邻两次重复之间的标准差:也就是从一个重复到下一个,答案的变动幅度。一个问题无论具有怎样的噪声,在两种批量策略下都具有同样的噪声。批量并不会新增噪声。两种方式下,大多数答案在 5 次重复中返回值完全一致——每次调用都得到同一个数值,标准差恰好为 0.0。
成本和速度确实会变。文档在每次请求中都占大头。N 次单题调用要付 N 次文档费用,外加 N 次往返;而批量调用只需付一次。文档越大,这笔节省就越接近完整的 N 倍。
本例是一个合规简报场景。文档是 Wikipedia 上的 GDPR 词条(约 54,000 字符,属于文档主导型负载,文档构成每次请求的绝大部分),合规团队要核查 13 个事项:8 个 Noul 问题、2 个 Choice 问题,以及 3 个 Score 问题。
环境准备
pip install ipython 'cooksafe>=0.2.0,<0.3.0'然后设置环境变量 TYPESAFE_API_KEY。
import json
import os
import urllib.request
from pathlib import Path
from statistics import mean, stdev
from time import perf_counter
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, ChoiceAnswer, Noul, NoulAnswer, Score, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
PRICE = (
0.042,
0.00,
) # $ per 1M tokens (input, output); TypeSafe jev-1.12 as of 2026-09, see README
RUNS = 5 # repeats per batching strategy, to estimate each answer's run-to-run std dev
client = TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], timeout=120.0)
json_cache = JsonCache(Path("json_cache.json"))文档:Wikipedia 上的 GDPR 词条
以纯文本形式从该词条的一个固定版本拉取,并和 API 调用一起缓存到 json_cache.json 中。这样即便线上词条被编辑,文档及其数值也保持不变。
WIKIPEDIA_REVISION = 1363040264 # "General Data Protection Regulation", as of 2026-07
@json_cache
def fetch_article(revision_id: int) -> str:
url = (
"https://en.wikipedia.org/w/api.php?action=query&format=json"
f"&prop=extracts&explaintext=1&revids={revision_id}"
)
request = urllib.request.Request(
url, headers={"User-Agent": "typesafe-cookbook/1.0"}
)
with urllib.request.urlopen(request) as response:
pages = json.loads(response.read())["query"]["pages"]
return next(iter(pages.values()))["extract"]
DOCUMENT = {
"source": f"https://en.wikipedia.org/?oldid={WIKIPEDIA_REVISION}",
"text": fetch_article(WIKIPEDIA_REVISION),
}
print(f"{len(DOCUMENT['text']):,} characters")
display(Markdown(f"📄 [Read the pinned Wikipedia revision]({DOCUMENT['source']})"))53,777 characters问题集合:8 个 noul 题 + 2 个 choice 题 + 3 个 score 题
每个答案按题型追踪一个数值:
Noul:「yes」的概率。Choice:最大概率(max prob),即被选中标签的概率。criteria把每个标签映射到其含义。Score:归一化到 0-1 的得分(normalized score),即原始分数除以最高档位。criteria列出从档位 0 开始的各档位描述。
QUESTIONS = {
"breach_72h": Noul(
instructions="Must a personal data breach be reported to the supervisory authority within 72 hours?"
),
"applies_non_eu": Noul(
instructions="Does the regulation apply to organisations established outside the EU that offer goods or services to people in the EU?"
),
"dpo_all_orgs": Noul(
instructions="Must every organisation appoint a Data Protection Officer, regardless of what data it processes?"
),
"pre_ticked_consent": Noul(
instructions="Can valid consent be obtained through pre-ticked boxes or inactivity?"
),
"right_erasure": Noul(
instructions="Does the regulation grant individuals a right to erasure of their personal data?"
),
"data_portability": Noul(
instructions="Does the regulation include a right to data portability?"
),
"us_federal_law": Noul(instructions="Is the GDPR a United States federal law?"),
"criminal_penalties": Noul(
instructions="Does the GDPR itself impose criminal penalties such as imprisonment?"
),
"instrument_type": Choice(
instructions="What kind of EU legal instrument is the GDPR?",
criteria={
"Regulation": "Directly binding law in all member states, no national implementation needed.",
"Directive": "Sets goals that member states implement through national law.",
"Treaty": "An international treaty between states.",
"Recommendation": "Non-binding guidance.",
},
),
"max_fine": Choice(
instructions="What is the maximum administrative fine for the most serious infringements?",
criteria={
"TwentyM_or_4pct": "Up to EUR 20 million or 4% of annual worldwide turnover, whichever is greater.",
"TenM_or_2pct": "Up to EUR 10 million or 2% of annual worldwide turnover, whichever is greater.",
"FixedCap": "A fixed amount not tied to turnover.",
"NoFines": "The GDPR provides no administrative fines.",
},
),
"individual_rights": Score(
instructions="How strong are the rights the GDPR grants to individuals over their data?",
criteria=[
"None: individuals get no rights over their data.",
"Weak: a right to be informed, but little control.",
"Moderate: access and correction rights, but limited means to act on them.",
"Strong: access, erasure, portability, and objection rights, with enforcement behind them.",
],
),
"penalty_severity": Score(
instructions="How severe are the penalties the GDPR provides for non-compliance?",
criteria=[
"None: no penalties of any kind.",
"Symbolic: small fixed fines unlikely to change behavior.",
"Substantial: fines large enough to matter to most companies.",
"Severe: fines scaled to global revenue, material even to the largest companies.",
],
),
"compliance_burden": Score(
instructions="How heavy is the compliance burden the GDPR places on organisations?",
criteria=[
"Negligible: no meaningful obligations.",
"Light: a few notices and disclosures.",
"Moderate: documented processes and some dedicated roles for larger processors.",
"Heavy: records, impact assessments, officers, and breach procedures for many organisations.",
"Extreme: obligations so demanding that ordinary organisations cannot fully comply.",
],
),
}
N = len(QUESTIONS)
METRIC = { # question type -> the one number we track per answer
Noul: "p(yes)",
Choice: "max prob",
Score: "normalized score",
}两种方式各问 5 次
ask() 把任意子集的问题连同文档一起发送,并将每个答案归约为其唯一被追踪的数值。每次调用中的文档都完全一致。
两种批量策略各跑 RUNS = 5 次,每个问题在每种策略下都得到 5 个答案,足以比较均值(两种方式结果一致吗?)和标准差(批量会增加噪声吗?)。调用结果都缓存到 json_cache.json 中,该文件随实战示例一起发布,所以重新渲染是免费的;删除该文件后即可重新实时运行。
@json_cache
def ask(keys: tuple[str, ...], run: int):
"""One TypeSafe call -> ({key: tracked metric}, input_tokens, output_tokens, latency_s);
``run`` only forces a distinct live call per repeat."""
started = perf_counter()
response = client.system_one(
state={"article": DOCUMENT},
questions={key: QUESTIONS[key] for key in keys},
model=TYPESAFE_MODEL,
)
values = {}
for key in keys:
answer = response.answers[key]
if isinstance(answer, NoulAnswer):
values[key] = answer.noul
elif isinstance(answer, ChoiceAnswer):
values[key] = max(answer.probabilities.values())
else:
values[key] = answer.score / (len(QUESTIONS[key].criteria) - 1)
return (
values,
response.usage.input_tokens,
response.usage.output_tokens,
perf_counter() - started,
)
def priced(result):
"""({key: metric}, in_tokens, out_tokens, latency) -> ({key: metric}, cost_usd, latency)."""
values, input_tokens, output_tokens, latency = result
return values, input_tokens / 1e6 * PRICE[0] + output_tokens / 1e6 * PRICE[1], latency
# Price after cache retrieval, so a price change needs no new calls.
batched = [
priced(ask(tuple(QUESTIONS), run)) for run in range(RUNS)
] # all N in one call, x RUNS
singles = [
{key: priced(ask((key,), run)) for key in QUESTIONS} for run in range(RUNS)
] # N x 1, x RUNS批处理不会改变答案
每个问题:在每种批处理策略下,其跟踪指标在 5 次重复中的均值和标准差。如果批处理改变了答案,那么批处理列就会与单次列不同。均值偏移意味着偏差,标准差变大意味着噪声。
print(
f"{'question':<22}{'metric':<18}{'batched mean':>13}{'single mean':>12}"
f"{'batched std':>13}{'single std':>12}"
)
for key, question in QUESTIONS.items():
batched_values = [values[key] for values, _cost, _latency in batched]
single_values = [singles[run][key][0][key] for run in range(RUNS)]
print(
f"{key:<22}{METRIC[type(question)]:<18}{mean(batched_values):>13.3f}"
f"{mean(single_values):>12.3f}{stdev(batched_values):>13.4f}{stdev(single_values):>12.4f}"
)question metric batched mean single mean batched std single std
breach_72h p(yes) 0.804 0.814 0.0055 0.0055
applies_non_eu p(yes) 0.990 0.990 0.0000 0.0000
dpo_all_orgs p(yes) 0.030 0.030 0.0000 0.0000
pre_ticked_consent p(yes) 0.040 0.040 0.0000 0.0000
right_erasure p(yes) 0.990 0.990 0.0000 0.0000
data_portability p(yes) 0.990 0.990 0.0000 0.0000
us_federal_law p(yes) 0.010 0.010 0.0000 0.0000
criminal_penalties p(yes) 0.108 0.108 0.0045 0.0084
instrument_type max prob 1.000 1.000 0.0000 0.0000
max_fine max prob 1.000 1.000 0.0000 0.0000
individual_rights normalized score 1.000 1.000 0.0000 0.0000
penalty_severity normalized score 1.000 1.000 0.0000 0.0000
compliance_burden normalized score 0.750 0.750 0.0000 0.0000按题型阅读这张表:
- Choice(选择题)、Score(打分题)以及 8 道 Noul(是非题)中的 6 道,在 5 次重复中得到的答案完全一致:两种批处理策略下的标准差都恰好为 0.0,每次批处理调用和单次调用返回的都是同一个数字。一次性传入 N 个问题,与每次传入一个问题共 N 次调用,得到的答案相同。
breach_72h和criminal_penalties带有一点逐次运行的采样噪声,且这种噪声在两种批处理策略下的规模相同,均值的差异也在该噪声范围内。噪声是问题本身的属性,而不是批处理方式的属性:批处理既不会偏移答案,也不会带来额外的方差。
无论怎么看,都不存在批处理效应:没有任何问题的答案依赖于与其共享同一请求的其他 12 个问题。
唯一的区别:成本和速度
答案相同,账单不同。那篇约 54,000 字符的文章主导了每一次请求,因此:
- 成本:13 次单问题调用会重复发送这篇文章 13 次;批处理调用只发送一次。无论你如何发起调用,这种节省都成立。
- 速度:图中汇总了 13 次单次调用的延迟,因此假设它们按顺序执行。如果改为并发执行,差距会缩小,但 13 倍的 token 成本不会变。
token 计数和延迟与答案一起被缓存;成本在之后才被施加,两者都取 5 次运行的平均值。
batched_cost = mean(cost for _values, cost, _latency in batched)
batched_latency = mean(latency for _values, _cost, latency in batched)
singles_cost = mean(
sum(singles[run][key][1] for key in QUESTIONS) for run in range(RUNS)
)
singles_latency = mean(
sum(singles[run][key][2] for key in QUESTIONS) for run in range(RUNS)
)
print(f"{'batching':<24}{'calls':>6}{'cost':>12}{'total time':>12}")
print(
f"{f'one call, all {N}':<24}{1:>6}{'$' + format(batched_cost, '.6f'):>12}{format(batched_latency, '.2f') + 's':>12}"
)
print(
f"{f'{N} calls, one each':<24}{N:>6}{'$' + format(singles_cost, '.6f'):>12}{format(singles_latency, '.2f') + 's':>12}"
)
print(
f"\nbatching: {singles_cost / batched_cost:.1f}x cheaper, {singles_latency / batched_latency:.1f}x faster"
)batching calls cost total time
one call, all 13 1 $0.000497 0.27s
13 calls, one each 13 $0.006090 2.71s
batching: 12.2x cheaper, 10.0x faster在 TypeSafe playground 中打开
同一篇文章和同样的 13 个问题,打包成一个分享链接。打开它即可在线重新运行这场简报,会得到与之前相同的数字。
playground_link = make_playground_link(
{"article": DOCUMENT}, QUESTIONS, models=[TYPESAFE_MODEL]
)
display(
Markdown(
f"🔗 [Open this article + questions in the TypeSafe playground]({playground_link})"
)
)