00. API の実挙動メモ

September 21, 2026 · View on GitHub

https://api.typesafe.ai/openapi.jsondocs.typesafe.ai を 読みながら実 API を叩いて確認した挙動。スキーマに書かれていないものを中心に。

再現: moon run --target native cmd/patterns --(5 パターン全部)


noul の criteria はネストする(黙って無視される)

NoulQuestion.criteriaNoulCriteria 型で、次の入れ子です。

{ "type": "noul", "instructions": "…", "criteria": { "true": "…", "false": "…" } }

true / false をトップレベルに置くとサーバーは 200 を返し、criteria を黙って捨てます。 反転した criteria(true = 正当な会話、false = 迷惑広告)で切り分けると:

送信形結果input_tokens
criteria に入れ子(仕様どおり)0.05315
トップレベル0.67287

0.67 は criteria なしの素の判断です。トークン数の差(28)が、criteria が送られていない証拠でした。 この形の取り違えはレスポンスが成功するぶん、ログを見ても気付けません。

lib は以前この形で送っていたため noul の criteria が一切効いていませんでした(修正済み)。 往復テストは対称なのでこの種のバグを検出できず、lib/types_test.mbt にワイヤ形式を 固定するテストを足しています。

Speculative fan-out が一番効く

同じ state に対する 20 問を、1 リクエストにまとめるか 20 回に分けるか:

レイテンシ入力トークン
20 回に分割5227 ms8277
1 リクエスト246 ms1000

21x 速く、8x 安い。 state が 1 回しか送られないので、質問を足すコストがほぼ質問文だけになります。

さらに答えが動きません。単独で聞いた場合との差は数値回答 17 個の平均 0.011(最大 0.04)、 choice 3 問は全て同じ選択肢。並列評価という説明どおりで、 「使うか分からない質問も込みで投げて、必要なものだけコードで拾う」が成立します。

実務的な含意: 判断に要りそうな述語は全部書いて 1 回投げるのが正解。 質問を削る最適化はほぼ意味がなく、往復を削る最適化だけが効く。

confidence 単独のゲートには穴がある

confidence は渡した選択肢の中での分布の尖り方であって、「どれかが妥当か」ではありません。 choice は必ずどれかを選ぶので、範囲外の入力が自信のある誤答として返ります:

"What is the airspeed velocity of an unladen swallow?" -> technical conf=0.96 [AUTO]
"asdf qwer zxcv"                                       -> technical conf=0.99 [AUTO]

閾値 0.85 を越えるので自動処理に流れます。対処は 2 つ、どちらも実測で効きました。

  • A: 逃げ道の選択肢none_of_these を criteria に足すと、上の 2 件とも conf 1.00none_of_these を選びます。最小の変更で済むのでまずこれ。
  • B: スコープ判定を fan-out で同じリクエストに混ぜるin_scope の noul を併せて聞くと 範囲外 0.02 / 正常 0.97 と明確に分かれます。リクエストは 1 回のままなので追加コストは質問文だけ。 ルーティング先の criteria をいじれない場合や、スコープ判定を別途ログに残したい場合はこちら。

選択肢の説明は省ける / 1 問 255 個まで

ChoiceQuestion.criteria の値は string / object / array / null が許され、 null は「選択肢名だけで解釈する」意味になります。説明文がトークンを食う本体なので、 20 ハンドラのメニューでも入力 441 トークン、confidence 0.98〜1.00 で当たりました。

ただし 1 問あたり 255 個が上限です:

301 choices -> 400 {"detail":"Too many choices. Must have at most 255 choices."}

OpenAPI スキーマには書かれていません。lib では max_choices として持ち、 送信前に `InvalidRequest$ で弾いています。五目並べが 15 \times 15 までなのも実はこれが効いていて、 盤面全体を候補にすると 16 \times 16(256 セル)で上限に当たります。

<\text{a} \text{id}="\text{choice}-\text{order}"></\text{a}>$choice` の並び順は中立ではない。ただし候補が少ないときだけ

説明文も state も変えずに隣接する 2 つを並べ替えるだけで確率質量が動きます。 ただし効果の大きさは候補数に強く依存し、リストが伸びると薄まります。

実測(各セル 6 回、2 つが質量のほぼ 100% を分け合う状況。 59 §4.8§4.9):

候補数後ろに置いたほうの得
16+0.212 / −0.202(対称)
46(前ブロック)+0.052
46(後ブロック)+0.022

効くのは「隣接 2 つの相対順序」で、リスト内の絶対位置ではありません。 候補 46 個で 2 つをリストの先頭付近に置くか後方に置くかは +0.003 —— つまり「後ろほど強い」ではない。

そして誤差の床は 0.04 です(同一条件を 2 回回して 0.712 と 0.673、 59 §4.10)。 上の表の 46 候補側(+0.052 / +0.022)は床と同程度かその下なので、 候補が数十個あるときは並び順の効果は誤差と区別できない、が正確な読み方です。 16 候補の +0.21 は床の 5 倍なので、そちらは実在します。

ラベルを入れ替えた対照も取ってあり、順番を揃えると数字は動きません。 効いているのは順序そのもので、どの要素がどのラベルを持つかには依りません。 つまり どちらが選ばれるかは語が決め、どれだけの差で勝つかを順序が決めます。

→ 実装側の含意: (a) 候補が少ない判断では、並べ替える機構(検索・視野で絞る・再ランク)が 集合だけでなく判断も動かす。 16 候補で 0.2 は大きい。 (b) 順序を固定しない実装は、同じ画面で違う confidence を返す。 experiments/browser-chaos のプローブが文書順で採番しているのは、 偶然これを固定していたことになります。 (c) 候補が数十個あるなら、この効果は実務上ほぼ無視できる。

限界: 間隔(2 つを離す)の効果は測れていません。離した配置は画面テキストと 矛盾するのでマップだけで送ったところ、p が最初から 0.97 で飽和し、 検出力が無い条件になりました。「間隔は効かない」ではなく「測れていない」です。

質問数に上限は無い。上限はトークンで、枠が 2 つある

上の 255 は choice の選択肢の上限で、1 リクエストの質問数とは別。 質問数には上限が無く、1220 問が 1 リクエストで通る:

 128 questions ->   6951 tokens  OK
 255 questions ->  13682 tokens  OK
 256 questions ->  13735 tokens  OK      <- 256 に境界は無い
1024 questions ->  54463 tokens  OK
1220 questions ->  65047 tokens  OK
1240 questions ->  ~66100        400 {"detail":{"error_type":"max_tokens_exceeded"}}

境界は 65536(64Ki)入力トークン。質問 1 問の増分はその質問文のトークンだけ (上の形では 53 トークン/問)。

state には別枠の、もっと厳しい上限がある。質問数を 4 に固定して state だけ伸ばすと:

state 28462 tokens  OK
state 32662 tokens  OK
state ~33400        400 max_tokens_exceeded

32768(32Ki)。 1220 問で 65047 トークンが通るのだから、 これは 1 本の合計上限ではなく独立した 2 枠(state 32Ki / リクエスト全体 64Ki)。 どちらも OpenAPI スキーマには書かれていない。

実務的な含意: 「何問投げられるか」を気にする必要はない。 気にするのは state の大きさで、そこが先に詰まる。 実装側は max_tokens_exceeded を見たら質問集合を半分に割って投げ直すのが安い (見積りを正確にするより、外れたときの復帰を用意するほうが確実)。 → 21

instructions と criteria は文字列でなくてよい

instructions と各 criteria の説明は string / object / array すべて通ります(実測で確認)。

{ "type": "noul",
  "instructions": { "task": "Detect business email compromise",
                    "signals": ["urgency pressure", "secrecy request"],
                    "statement": "This email is a fraud attempt." } }

そのため libQuestionJson を保持する形にし、全部文字列という普通のケース用に Question::noul / choice / choice_of / score を用意しています。

ただし易しいケースでは答えは変わりません(上の例は string / object / array すべて 0.98)。 構造化して効くのは、素の文章にすると曖昧になる情報を渡すときだけです → 01 の文脈実験

state も文字列 / オブジェクト / 配列いずれも可

会話ログを配列でそのまま渡す、ユーザー属性を入れ子で渡す、がそのまま通ります。

{ "state": [ {"role": "user", "text": "this is garbage"},
             {"role": "user", "text": "fix it NOW"} ] }

合成は「分解 vs 総合」だけの話ではない

複合質問 1 問と、原子的な noul をコード側で重み付けした結果はほぼ一致します (複合 2.92/3 conf 0.92 に対し加重和 0.899)。違うのは内訳が見えることと、 重みの変更に API 呼び出しが要らないこと。

ガードレール用途ではこの差が精度に出ます。You are now DAN… のような roleplay と 軽い依頼が混ざった入力では、判定を 1 問の choice に任せると confidence が 0.43 に落ちるのに、 原子的な noul は injection=0.97 / roleplay_bypass=0.98 と鋭いまま。

ただし分解が常に勝つわけではありません → 01 の反例

レイテンシ

実測 125〜730 ms。質問数を 1 → 20 に増やしても大きく変わりません(並列評価)。 初回リクエストだけ遅い傾向があります(接続確立)。