JevCode / エコシステム事例

並列質問

GDPRのウィキペディア記事に対して13問の規制関連ブリーフィングを実行し、すべての質問を1回のTypeSafe呼び出しにバッチ処理することで、回答内容に変更はなく、コストが12.2倍、速度が10.0倍向上することを示している。

本文はenからの機械翻訳です。校正は未実施で、参考情報としてのみご利用ください。

ソース: docs.typesafe.ai/cookbooks/parallel_questionscookbookrecipe
One request fanning out into four questions

あなたは1つのドキュメントと、それに関するN個の質問を持っています。N個の質問をすべて1つのリクエストに含めて送信するか、または各質問を1つずつ含むN個のリクエストを送信することができます。TypeSafeでは、どちらの方法でも回答結果は同じになります。各質問はドキュメントに対して個別にスコアリングされるため、その回答はリクエスト内の他の内容に依存しません。

それを確認するため、クックブックでは各質問を両方向から複数回問い合わせています。すべての N を1つのリクエストで送信する場合と、1質問ごとにリクエストを送信する場合の両方で、実行ごとの標準偏差を比較します。これは、回答が繰り返すたびにどれだけ変動するかを示します。質問に固有のノイズは、バッチ処理戦略のどちらでも同じように発生します。バッチ処理はノイズを追加しません。どちらの場合も、5回の反復で回答はすべて同一で、呼び出しごとに同じ値となり、標準偏差は正確に 0.0 でした。

コストと速度は変動します。ドキュメントはすべてのリクエストで支配的な存在となります。N 回の単一質問呼び出しは、N 回のラウンドトリップでこれを N 回分支払うのに対し、バッチ呼び出しは一度だけ支払います。ドキュメントが大きければ大きいほど、その節約は完全な Nx に近づきます。

ここでのケースは規制関連のブリーフィングです。対象文書はGDPRに関するWikipediaの記事 (約54,000文字、文書がリクエストの大部分を占める文書中心のワークロード)であり、 コンプライアンスチームは13項目の確認を求めています:8 Noul の質問、2 Choice の質問、および3 Score の質問です。

セットアップ

pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/

その後、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"))

ドキュメント:GDPRに関するウィキペディアの記事

json_cache.jsonの固定リビジョンから記事としてプレーンテキストで取得し、API呼び出しの隣にキャッシュされているため、ライブ記事が編集されても、ドキュメントとその数値は固定されたままになります。

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

📄 固定されたWikipediaの改訂版を読む

質問:8つのnouls + 2つのchoices + 3つのscores

回答ごとに、タイプごとに1つの数値を追跡:

  • Noul: 「yes」の確率。
  • Choice: 最大確率、選択されたラベルの確率。criteriaは各ラベルをその意味にマッピングする。
  • Score: 0〜1に正規化されたスコア、トップレベルで割ったスコア。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",
}

2つの方法で、それぞれ5回ずつ質問する

ask()は、質問の任意のサブセットをドキュメントとともに送信し、各回答を単一の追跡番号に圧縮します。ドキュメントはすべての呼び出しでバイト単位で同一です。

両方のバッチ処理戦略は RUNS = 5 回実行され、各戦略に対して質問ごとに5つの回答が得られます。 これにより、平均値の比較(2つは一致するか?)と標準偏差の比較(バッチ処理はノイズを加えるか?)が可能です。 呼び出しは 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

質問タイプ別に表を読む:

  • Choices、スコア、および8つのNoulのうち6つは、5回の反復全体で同一の結果が返される: 両方のバッチ処理戦略において標準偏差は正確に0.0であり、バッチ処理された呼び出しも単一の呼び出しも 同じ数値を返す。Nの質問を含む1回の呼び出しは、各1質問ずつのN回の呼び出しと同じ回答を返す。
  • breach_72hおよびcriminal_penaltiesには若干の実行間サンプリングノイズがあり、その大きさは 両方のバッチ処理戦略で同じであり、平均値はそのノイズの範囲内で一致する。ノイズは質問の性質であり、バッチ処理の方法ではない:バッチ処理は回答をシフトさせず、分散も追加しない。

いずれにせよ、バッチ処理による影響はありません:ある質問への回答は、同じリクエストに含まれる他の12の質問に依存しません。

唯一の違い:コストと速度

同じ答え、異なる請求書。約54,000文字の記事がすべてのリクエストを支配しているため、

  • コスト:13回の単一質問呼び出しは記事を13回再送信しますが、バッチ呼び出しは1回だけです。呼び出しの発火方法にかかわらず、この節約効果は維持されます。
  • 速度:数値は13回の単一呼び出しのレイテンシを合計したものであり、それらが連続して実行されると仮定しています。並行して発火させるとギャップは縮まりますが、13倍のトークンコストはそのまま残ります。

トークン数とレイテンシは回答とともにキャッシュされ、コストはその後適用され、両方とも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 プレイグラウンドで開く

同じ記事と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})"
    )
)

TypeSafe プレイグラウンドでこの記事と質問を開く →