はじめに

こんにちは。and factory でサーバーサイドエンジニアをしている松ヶ浦です。

Jevが話題ですね。

僕も公開されたその日のうちにウェイトリストへ登録して、翌日アクセスキーを付与されると、休憩時間を使って遊びました。

驚いたのは速さでした。AIを使ったというより、ローカルの関数を呼んだときの感触に近く、一周回ってAIを使っていない普通のアプリを触っているような感覚になりました。

ひとしきり遊んでから、学生時代にチームで作ったものを思い出しました。Xのタイムラインから誹謗中傷を検知して隠すChrome拡張です。判定はGPT-3.5のAPIに投げていました。

あれ、Jevを使えば同じことがずっと速くできるのでは。

そう思って作り直したので、その記録です。結論から書くと、1リクエストに8投稿を詰めて質問を絞るpackモードでは、8投稿の判定が244msで返りました。1投稿ずつ全6問を聞くsoloモードでは2263msです。拡張の既定はpackモードで、各ユーザーが自分のAPIキーで直接Jevを呼ぶ形にしたため、バックエンドも要らなくなりました。

Jevは何を返すモデルなのか

JevはTypeSafeが公開したSystem Oneモデルです(TypeSafeの発表記事)。System Oneは、人間の速い直感的な判断(システム1)になぞらえた呼び名で、じっくり考えて文章を組み立てるのではなく、即座に答えを返すことに振り切ったモデルを指します。

いちばん大きな特徴は、文字列を生成しないことです。返るのは型の付いた構造化された値だけで、公式は「モデルは型エラーを起こさない」と書いています。

レスポンスの形式には3種類あります。

  • Noul: yesである確率を返す。
  • Choice: 選択肢から1つ選び、分布とconfidenceを返す。
  • Score: 順序付きのレベルに対する確率加重値とconfidenceを返す。

公称のレイテンシは70〜500ms、価格は入力が100万トークンあたり0.042ドルで、出力は無料とされています。

そして設計上いちばん重要なのが、stateは1度しか読まれず、すべての質問が並列に評価されるという性質です。これは後半でそのまま速度の話につながります。

もう1つ、ChoiceとScoreの答えにはcalibrated confidenceという確信度が付きます(Noulには付きません)。較正されているという意味で、confidenceが高いほど実際の正答率も高くなります。これも後で使います。

コラム: OpenAIのModeration APIとは何が違うのか

この手のモデルの話を聞いて、昔からあるOpenAIのModeration APIを思い出しました。文章を生成せず、テキストを投げると分類結果が返り、しかも速いモデルです。

違うのは、誰が判断基準を決めるかです。

Moderation APIは、カテゴリがサービス側で固定されています。ヘイト、ハラスメント、自傷、性的、暴力といった区分ごとのスコアと、有害である可能性があるかどうかのフラグ(flagged)が返ります。用途が有害コンテンツの検知に寄せてあるぶん、そのままで強い一方、区分そのものは動かせません。「この投稿は内輪のノリか」といった、自分のアプリにしか意味のない問いは立てられません。

Jevは逆で、質問を自分で設計します。stateに文脈を置いて、そこに対して聞きたいことを自分の言葉で並べます。返ってくるのは、その質問に対する確率やスコアです。汎用の分類・スコアリング・抽出のための道具で、モデレーション専用ではありません。

今回のアプリは、まさに「軸そのものを自分で決めたい」側でした。判定の肝は「強い言葉が入っているか」ではなく「悪意があるか」の見極めで、これは既成のカテゴリでは表現できません。

学生時代にチームで作った「へいたん」

へいたんは、学生時代にチームで作ったChrome拡張です。Xのタイムラインを読み取り、誹謗中傷と判定した投稿を隠します。構成は、拡張からAWS Lambda(FastAPI)を叩き、その先でGPT-3.5を呼んで、結果をRedisに7日キャッシュするというものでした。

タイムラインの判定は、GPT-3.5に「この投稿の安全性レベルをJSONで返せ」と頼む方式です。判定用のプロンプトは13行ほどで、例文は入っていません。

へいたんには、投稿前に強い言葉を穏やかな表現へ書き換える提案機能もありました。こちらのプロンプトは85行あり、「変換する例」と「変換しない例」を列挙しています。「返答には変換結果のみを含んでください」「諭すのではなく変換してください」といった、出力の形式を守らせるための念押しも何度か出てきます。モデルに言うことを聞かせるための記述が、判断基準そのものと同じ場所に同居していました。文章を生成するモデルを相手にする以上、当時はこう書くしかなかった部分です。

作り直す

作り直した版はre:へいたんと名付けました。WXT 0.21とReact 19で書いています。対象はタイムラインの検閲だけです。書き換え提案の機能は、Jevが文字列を生成しないので移植していません。

構成はこうなりました。

1
2
3
Chrome拡張 (WXT + React)
  ↓
Jev

以上です。へいたんではAPIキーをサーバー側に持つ必要があり、そのためにLambdaを置いていました。re:へいたんでは、各ユーザーが自分で発行したAPIキーを拡張の設定に入れ、拡張から直接Jevを呼びます。キャッシュも拡張の中に持たせたので、バックエンドが要らなくなりました。

判定の軸

へいたんの書き換え用プロンプトに並んでいた例文と、re:へいたんの判定は、同じ方向を向いています。どちらも「強い言葉が入っているか」ではなく「悪意があるか」で見分けようとしています。変わったのは、その基準をどこに置くかです。

Jevには文章を渡せますが、レスポンスは文章ではなく構造化されたデータです。そのため「こういう投稿は変換しないでください」と書いても、その指示に従った文章は返ってきません。判断基準は、質問の形に割る必要があります。

投稿1件を単独で評価するとき、6つの質問を投げています。

1
2
3
4
5
6
sev     Score   読み手を傷つける度合い(3段階)
attack  Noul    特定の個人・集団を貶める意図があるか
slur    Noul    侮蔑語・差別語・人格否定を含むか
threat  Noul    暴力や危害をほのめかしているか
sexual  Noul    相手を辱める性的表現を含むか
banter  Noul    内輪のノリ・自虐・愛のある誇張か

書き換え用のプロンプトは「変換しない例」として バカおもろいんだけどwwww と マジ尊い…死ぬ… を挙げていました。強い語を含むのに悪意がない文です。同じことをJevでやると、banter という独立した質問になり、スコアから引く減点項として扱えます。

6つの答えは、次の式で1本のスコアに畳んでいます。sev は3段階のScoreなので、レベル数から1を引いた値で割って0〜1に正規化してから使います。

1
2
3
4
5
harm   = max(slur, threat, sexual)
intent = 0.6 * attack + 0.4 * harm   (slur を聞いていない pack モードでは attack のみ)
base   = 0.5 * sev + 0.5 * intent
damp   = banter * (1 - max(attack, threat))
score  = clamp01(base - 0.4 * damp)

harm は加点側、damp は減点側です。damp の (1 - max(attack, threat)) が働くので、攻撃意図や脅迫がはっきりしているときには、いくら砕けた口調でも減点されません。「ぶち殺すぞお前」を内輪のノリとして見逃さないためです。

このスコアを、次のルールで表示に落とします。

  • 隠す: scoreが0.72以上で、かつ sev のconfidenceが0.55以上
  • ぼかす: scoreが0.45以上(0.72以上でもconfidenceが0.55未満ならここに留める)
  • 素通し: それ以外

confidenceで「隠す」を止めているのは、自信のない判定で投稿を丸ごと消すと、外した分だけタイムラインが静かに欠けるためです。ぼかしであれば、読むかどうかを本人が選べます。TypeSafeのドキュメントではConfidence-gated routingと呼ばれています。

動かしてみる

実際の画面です。

判定を終えたタイムライン。攻撃的と判定された投稿は「過激な投稿の可能性があります」「誹謗中傷と判定した投稿です」の1行に置き換わり、「表示する」を押すと中身が現れる

画面に写っている投稿は、デモのために用意した非公開アカウントで、自分で投稿したものです。

※余談ですが、録画のために、投稿者のアイコンと表示名を伏せる機能も足しました。

実測

npm run probe を用意して、拡張を読み込まずに判定とレイテンシを測れるようにしました。叩いているのは API reference にある POST https://api.typesafe.ai/v1/systemone の1本だけです。拡張をビルドしなくても、リポジトリをcloneして次のコマンドで動かせます。

1
2
npm install
TYPESAFE_API_KEY=<発行したキー> npm run probe

キーは console.typesafe.ai で発行します。以下の計測は、macOS 14.6とNode.js 24.1.0の環境で実行しました。サンプルの8文は、へいたんの書き換え用プロンプトの例文をもとにしています。例文のテンプレートを具体的な文にしたもの、複数行の例文から1行を抜き出したもの、比較用に自分で足した映画の感想が混ざっています。

モードは2つあります。soloは1投稿につき6問を投げ、8投稿ぶんのリクエストを並列に出します。packは8投稿を1リクエストに詰め、1投稿あたりの質問を sev と attack の2問に絞ります。表の conf は sev のconfidenceです。

solo(1投稿6問 × 8リクエスト)

1
2
3
4
5
6
7
8
9
素通し  score=0.00  conf=0.97  1996ms  この映画、脚本が雑すぎて最後まで乗れなかった
隠す    score=0.93  conf=0.94  1998ms  お前が死んだら世界は平和になるよ
素通し  score=0.00  conf=1.00  2020ms  おはようございます
素通し  score=0.00  conf=0.95  2025ms  マジ尊い…死ぬ…
ぼかす  score=0.75  conf=0.50  2027ms  普通にそれしてるの頭悪いよね
隠す    score=0.96  conf=0.99  2028ms  ぶち殺すぞお前
素通し  score=0.00  conf=0.85  2069ms  バカおもろいんだけどwwww
ぼかす  score=0.72  conf=0.28  2247ms  そんなんだから彼氏できないんだよ
  合計 2263ms / 入力 8363 tokens

pack(8投稿16問 × 1リクエスト)

1
2
3
4
5
6
7
8
9
素通し  score=0.01  conf=1.00   244ms  おはようございます
素通し  score=0.13  conf=0.51   244ms  バカおもろいんだけどwwww
素通し  score=0.05  conf=0.87   244ms  マジ尊い…死ぬ…
素通し  score=0.12  conf=0.91   244ms  この映画、脚本が雑すぎて最後まで乗れなかった
隠す    score=0.83  conf=0.60   244ms  普通にそれしてるの頭悪いよね
隠す    score=0.94  conf=0.94   244ms  お前が死んだら世界は平和になるよ
隠す    score=0.93  conf=0.99   244ms  ぶち殺すぞお前
ぼかす  score=0.84  conf=0.45   244ms  そんなんだから彼氏できないんだよ
  合計 244ms / 入力 3779 tokens

soloは1リクエストあたり約2000msかかり、公称の70〜500msに収まっていません。原因は今回切り分けていません。一方でpackの244msは公称レンジの中です。Xのポストのように独立した短い文を大量に判定したいケースでは、packのほうが向いていそうです。

へいたんとの速度比較もしたかったのですが、当時の計測記録が残っていませんでした。記憶ベースでは、キャッシュが効かない場合に数十件をまとめて処理して5秒ほど、という感触です。件数も条件も揃っていないので、参考程度に見てください。

速さと引き換えに失ったもの

上の2つの出力を並べると、時間以外も変わっているのが分かります。

packでは slur・threat・sexual・banter を聞いていないので、式は score = 0.5 * sev + 0.5 * attack まで縮みます。加点側の harm が消える代わりに、減点側の damp も働きません。悪意のなさを測る banter が消えれば、判定は攻撃寄りに振れます。

バカおもろいんだけどwwww はその例です。判定こそ素通しのままですが、scoreは0.00から0.13に上がりました。packには banter による減点がないので、その分だけscoreが残ります。

削ったのは往復だけではなく、1投稿に割ける質問の数でもありました。

confidenceも変わっています。質問はそれぞれ独立に評価されるので、質問を減らしたこと自体は sev のconfidenceに影響しないはずです。変わったのはstateの側で、soloでは投稿1件だけ、packでは8件が並んだ文章をstateとして渡しています。同じ投稿でも、周りに何があるかで確信度が動いたと見ています(検証はしていません)。

普通にそれしてるの頭悪いよね は、confidenceの歯止めが効いた例です。soloでもscoreは0.75で「隠す」の閾値0.72を超えていましたが、confidenceが0.50で0.55に届かず「ぼかす」に留まっていました。packではconfidenceが0.60に上がり、閾値をまたいで「隠す」になりました。

そんなんだから彼氏できないんだよ も、packではscore 0.84に対してconfidenceが0.45しかなく、「ぼかす」に留まりました。ただ、これは書き換え用プロンプトでは「変換する(有害)」側の例です。隠しきれなかったとも言えますが、ぼかしたうえで読むかどうかを本人に任せたと見ることもできます。消すか残すかをconfidenceで分けると、こうした境界の投稿がどちらに転ぶかを閾値で調整できます。

見えるものだけ判定する

へいたんから変えたところとして、IntersectionObserver で、画面から600px以内に入った投稿だけをキューに入れています。キューは60msだけ寝かせてから、たまった投稿をまとめて判定に回します。

packモードでは、このとき2件以上たまっていれば最大8件ずつ1リクエストに詰めます。1件しかたまらなかったときは、その1件をsoloの6問で判定します。スクロールの速さによって、どちらの経路を通るかが変わります。

これは速くなったからこそ成立する作りです。スクロールしてきた投稿をその場で判定するので、数秒かかる判定では表示に追いつきません。packで詰めた場合は、今回の計測では244msで返っています。solo単独の1往復は今回測っていませんが、画面に入る600px手前から判定を始めているので、その分の猶予があります。検閲は見る前に間に合うかどうかが本質で、レイテンシがそのまま機能の可否を決めています。

備考

この実装で確かめられていないことや、使う前に知っておいてほしいことを並べておきます。

  • 日本語での精度は未検証。Jevのモデルドキュメントは、CJKの文字体系を「扱えるが英語と同等ではない」としている(日本語を名指ししているわけではない)。今回の閾値(0.45と0.72)とconfidenceの下限(0.55)は、実際のタイムラインで詰める前の初期値である。
  • 上の実測は、2026年9月18日に東京から、8文のサンプルに対して1回だけ行った計測。ネットワークの状況や時間帯で揺れる値であり、統計的な比較ではない。
  • へいたんの速度は、前述のとおり記憶ベース。当時の計測記録は残っていない。
  • 拡張は、タイムラインに表示された他人の投稿の本文をTypeSafe(米国)のAPIに送信する。
  • packモードでは、他人の投稿8件が同じstateに入る。ある投稿の文面が、別の投稿の判定を動かしうる(プロンプトインジェクションの余地がある)。
  • 使うモデルは既定で jev-latest で、バージョンを固定していない。モデルの更新で同じ入力の判定が変わりうる。
  • 今回の計測には、TypeSafeがアカウントに付与している5ドル分の無償クレジットを使った。この記事はTypeSafeからの依頼や提供によるものではない。
  • 投稿の取得はXの data-testid に依存している。仕様が変われば判定が行われなくなり、タイムラインは素通しになる。

まとめ

Jevは並列化が強いモデルです。推論自体が速いうえに、8投稿ぶんの判定が1往復でまとめて返ってくると、AIを使っているという感覚がなくなります。

しかも安い。この実験でかかった費用は1円未満でした。

Xのポストのように、短くて互いに独立した文を大量に判定する用途は、この並列性に向いています。1件ずつ投げると往復の数だけ遅くなりますが、まとめて詰めれば往復は1回で済み、モデル側の読み込みも1回のままです。ただし今回見たとおり、詰めるために質問を減らせば判定そのものも変わります。速さと精度のどちらを取るかは、質問の設計で決めることになります。

文脈を読みきれずに誤判定していそうな部分は、まだ残っています。前後の投稿までstateに入れれば改善する余地がありますし、入力が増えても往復が1回のままであれば、速さはある程度保てる見込みです。どこまで詰め込めるかは、次に測ってみたいところです。