title: "LLM の分類ラベルは誰も読んでいなかった。実害は縛ってあるほうから出た"
emoji: "🏷️"
type: "tech"
topics: ["ai", "llm", "設計", "オントロジー", "claudecode"]
published: true
AI 関連のニュースを集めて要約してくれるボットを自分用に作って動かしているのですが、やっていることは単純で、RSS を拾ってきて LLM に「これは技術者向けの速報か、記事のネタか、それとも捨てるか」を判定させ、GitHub Actions の cron で3時間おきに回しているだけです。利用者は私ひとり。
で、あるとき出力を眺めていたら、分類ラベルがひどいことになっているのに気づきました。5件出ていて、5件とも違う。
これは統制語彙でも入れて整理したほうがいいなと思って、SKOS だのタクソノミーだのを調べ始めたのですが、さあ実装しようかというところで「入れる前の状態を一度も測っていない」ことに気づきました。ビフォーが無ければアフターも語れないので、しぶしぶ測りに行ったわけです。
そこで想定が完全にひっくり返りました。
結論
- ばらついていたラベルは、その値を読んでいるコードが一箇所も無かった。だから何も壊れていなかった
- 逆に、値域を決めてあったフィールドのほうが実際の損失を2件出していた(片方は3値の enum、もう片方は 0〜100 の整数)
- 値の集合や範囲を固定しても、判断の質までは固定されない
そもそも何がばらついていたのか
判定の出力には分類っぽいフィールドが2つあって、片方は記事のカテゴリを自由記述で書かせる category、もう片方は L3(速報)/ L2(記事のネタ)/ drop(捨てる)の3値をとる line です。
問題は前者のほうで、速報として起案された5件を並べてみると、こんな具合になります。
【実測】2026-08-27 に outbox/*.md の front matter から取得(n=5)
| 記事 | category |
|---|---|
| Ray capabilities on SageMaker HyperPod | 新機能/仕様変更 |
| Agentic Resource Discovery (ARD) | 新製品/オープン仕様 |
| GPT‑5.6 in Kiro | 新機能/提供開始 |
| Disrupting a covert influence campaign | AIセーフティ |
| Jalapeño inference results | 新製品/推論チップ |
ここで先に断っておくと、この5件は同じ入力を5回投げた結果ではありません。内容の違う5本の記事それぞれに対する出力なので、カテゴリが5通りになること自体はまったく当たり前で、問題にしたいのは件数そのものよりばらつき方のほうです。
よく見ると妙な構造があって、上位語は 新製品 と 新機能 の2つに収まっているのに、スラッシュの後ろがまったく揃っていません。推論チップ は製品の種類、オープン仕様 はライセンス形態、提供開始 はイベントの種別と、3つの軸が平気で混ざっています。AIセーフティ に至っては階層が1段しかない。
分類体系としては崩壊していますよね。軸が違うものを同じ列に並べても、比較のしようがありませんから。ただし、これが実害を出しているかどうかは、この時点ではまだ分かりません。
制約が「構文」しかなかった
なぜこうなったのかを確かめるために、コードを全部見にいきました。
【実測】2026-08-27 に src/ tests/ を横断して category を検索
見つかった制約は3つだけで、score/verify.py の MAX_CATEGORY_LENGTH = 40 による長さ制限と、同じく改行や --- を含まないことのチェック、それから判定プロンプトの JSON 例に "価格改定" と "仕様" が書いてあること。前の2つは YAML の front matter を壊さないための構文チェックなので、意味には一切踏み込んでいませんし、3つ目は例示であって定義ではありません。
語彙のリストも、階層の定義も、分類軸の指定も、どこにも存在していませんでした。設定ファイルは4つあるのですが、そのどれ1つとして category を扱ってはいなかったんですね。
ここで1つ面白いことに気づきます。プロンプトの例示は 価格改定 仕様 という1階層なのに、実際の出力は 新製品/推論チップ と2階層になっている。誰も階層を作れとは言っていないのに、勝手に作っているわけです。
例を2件だけ置くと、LLM は足りない部分を自分で埋めてきます。これは後の回で効いてくる観察なので、頭の片隅に置いておいてください。
ところが、その値を読んでいるコードが1つも無かった
ここまで書くと統制語彙を入れる理由にしか見えませんし、実際そのつもりでした。
ただ、改善というのは下流に効いて初めて改善なので、実装に手をつける前に、この値がどこで使われているのかを確認することにしました。grep 一発で済む話ではあります。
grep -rn "category" src/ tests/
【実測】2026-08-27 に src/ tests/ を横断確認
ヒットしたのは3箇所だけで、しかも中身を見て思わず声が出ました。verify.py は長さと改行を検査するだけ、stage.py は front matter の1行として書き出すだけ、テストはその構文検査を検証しているだけでした。
値を読んで何かを決めている箇所は、システム全体でゼロ!投稿先を決めるわけでもなければ、検索に使うわけでもなく、集計もしていない。ファイルに書き出されて、それきりでした。
下流が存在しないので、ばらついたところで壊れるものが無いわけです。
ただし「実害ゼロ」と言うときの中身は、あくまで下流のコードが壊れなかったという意味に限られます。実際このラベルを眺めて統制語彙を調べ始め、それなりの時間を使っているので、人間の側のコストまでゼロだったわけではありません。
ただ、冷静に考えると、5件が5通りであること自体そんなにおかしいでしょうか?5件とも違う話題の記事なんだから、カテゴリが5通りになるのはむしろ自然なわけで。
「ばらついている」と「問題がある」は別の話でした。値の分布だけを見て問題だと判定していて、その値が何に使われているかを一度も確認していなかったことになります。
出力を眺めるだけなら grep "^category:" outbox/*.md で終わるのですが、それだと同じ間違いを踏みます。効いたのは2つ目の grep のほうでした。
一方、値域を決めてあるフィールドは実害を出していた
さて、もう1つの line を見ていきます。
こちらは設計した時点から enum で、VALID_LINES という許可値の集合があり、そこに無い値は検証で弾かれます。自由記述にしようとは一度も考えませんでしたが、その理由は明白で、下流が値を読んで分岐する設計になっているからです。
【実測】2026-08-27 にコードで確認したところ、verify.py は line not in VALID_LINES で弾き、drop ならそこで検証を打ち切り、L3 なら投稿文の有無を、それ以外なら骨子の有無を見ていました。cli.py のほうは L3 を速報として、L2 を記事のネタとして起案しています。
つまり同じシステムの中に、構造化の水準がまったく違う2つのフィールドが同居していたことになります。誰も読まない category は制約なしで、投稿先を決める line は enum。無意識のうちに、下流の有無で構造化の水準を選び分けていたことになりますが、設計判断としては、たぶん間違っていなかったのだろうと思います。
それでも損失は2件出た
line は enum で縛ってあるので、値の集合は保証されています。それでも損失は出ました。
以下は git のコミットメッセージに残っていた記録で、書いた時点では記事にするつもりが無かったものなので、都合よく書き直したりはしていません。
損失① 投稿すべきでないものが承認待ちに上がった
【一次情報】commit e2945dc(2026-08-26 02:15 JST)のコミットメッセージより:
実運用の初回(PR #6)で「ある企業が世論工作アカウント群を停止した」が速報として起案された。事実として正しく出典も明確だが、読んでも作るものが変わらない。この発信は技術者に向けたものなので、題材として外れている。
現物も残っていました。
line: L3
score: 55
category: "AIセーフティ"
title: "Disrupting a new covert influence campaign from Russia"
正解は drop です。
とはいえ L3 という値そのものは完全に正当で、enum の集合に入っていますし検証も通ります。壊れていたのは値そのものではありません。その値を選んだ判断のほうです。
損失としては、承認待ちの PR に載って自分の時間を消費したこと、それから気づかずに承認していたら技術者向けのタイムラインに技術と無関係な投稿が流れていたこと。後者は危なかった!
損失② スコアが団子になって優先順位が付かなかった
同じコミットメッセージに、もう1件記録が残っています。
【一次情報】commit e2945dc より:
初回は全12件が 55〜65 に集まり、承認する人が優先順位を付けられなかった。
score のほうは enum ではなく 0〜100 の整数という範囲で設計していて、verify.py が 0 <= score <= 100 を検査しています。それでも実際の出力は、100 点の幅のうち 10 点の範囲に密集しました。
後から確認できたのは、この12件のうち起案ファイルに score が残っている5件だけでした(記事のネタとして起案されたものは別形式で、score を持ちません)。
【実測】2026-08-27 に起案ファイルから取得(n=5・上の「12件」の部分集合)。55 が3件、60 が1件、65 が1件。
5件中3件が同じ値というのは、承認する側から見れば「全部だいたい同じ」という情報しか持たないわけで、選別の道具としてはまったく機能していませんでした。
こちらはばらつきすぎたせいで起きた損失ではありません。逆に、ばらつかなさすぎたことによる損失です。分類の失敗が、必ずしもバラバラになる方向に出るとは限らないというのは、ちょっと意外でした。
並べるとこうなる
ばらついていた category は下流ゼロで実害ゼロ、値域を決めてある line と score は下流ありで実害2件。ちょうど逆じゃないか、と思いました。
enum が保証してくれるのは「L3 か L2 か drop のどれかである」ことだけで、「その記事にとって L3 が正しい」ことまでは保証してくれません。
公式も「値の集合」と「判断の正しさ」を分けていた
自分の観測を書き終えてから公式ドキュメントを確認したら、OpenAI が同じことを明記していました。
【一次情報】Structured model outputs(2026-08-27 取得)
保証すること:
"Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema, so you don't need to worry about the model omitting a required key, or hallucinating an invalid enum value."
保証しないこと(同じページ):
"Structured Outputs can still contain mistakes. If you see mistakes, try adjusting your instructions, providing examples in the system instructions, or splitting tasks into simpler subtasks."
存在しない enum 値をでっち上げることは防ぐけれど、間違いは残る、と書いてあります。
注目したいのは対処法のほうで、挙がっているのは「指示を調整する」「例示をシステム指示に入れる」「タスクを分割する」の3つです。enum が効かないときの手当てとして公式が最初に持ってくるのが例示だというのは、言われてみれば当たり前でも、構造化を進めているときには一番見落とすところではないでしょうか。
統制語彙を作って、階層を定義して、関係まで書いたところで、割り当てを間違えれば下流は同じように壊れます。語彙が正しいことと、正しい語彙が選ばれることは、別の問題なので。
私がやったのは、語彙を作ることではなかった
損失①②を受けて最初に手を入れたのは、実はプロンプトでした。
【一次情報】commit e2945dc の差分より、判定プロンプトに追加した内容:
まず判定の第一の軸を1問に置きました。
これを読んだ人が「自分の技術選定・実装・運用が変わる」と判断できるか。
変わらないものは、事実として正しくてもdropにしてください。
次に落とす例を明示していて、入れるのは新モデル・新機能・API の追加や廃止・仕様変更・価格改定・性能の数値・SDK の更新あたり、落とすのは組織や人事の発表、資金調達、提携、セキュリティ事案の摘発やアカウント停止、ポリシー変更、イベント告知、導入事例、という具合に書き分けています。
そして実際に失敗した例を、そのままプロンプトに埋め込みました。
落とす例(実際に起案されてしまったもの): 「ある企業が、AI を使った世論工作アカウント群を停止したと発表した」——事実として正しく、出典も明確ですが、読んでも作るものが変わりません。
dropが正解です。
最後に L3 と L2 の分かれ目を「そのままでは意味が伝わらないか」という基準で定義し直しています。実装コストはほぼゼロで、Markdown を数十行足しただけでした。
これが効いたかどうかは、残念ながらこの記事では書けません。直したのが 2026-08-26 で、それ以降の出力がまだ十分に溜まっていないためです。
結局どこを見るべきだったか
3つにまとめます。
出力がバラバラであること自体は、問題ではありません。 その値を消費する下流があって、そこで何かが壊れて初めて問題になります。私は分布を見て「統制語彙が要る」と判断しかけたわけですが、下流を確認したら消費者がいませんでした。逆に言えば、下流を作る予定があるならその時点で構造化の水準を上げる理由が生まれるので、順序が逆になっていないかは一度確認する価値があると思います。
構造化の水準は、下流が要求する分だけで足ります。 このシステムは意識せずそうなっていたわけですが、もし「とりあえず全部構造化する」をやっていたら、誰も読まない category の語彙定義を保守し続けるコストだけが残っていたはずです。
構造は「あり得ない値」を防ぎますが、「あり得るが間違っている値」は防ぎません。 実際 line は enum で縛ってあったにもかかわらず、この記事で見たとおり誤判定しています。それに対して実際に打った手は語彙の強化ではなく、判断基準を明示するほうでした。ただしどちらが有効かを比べたわけではありませんし、効果も測っていないので、ここで言えるのはそちらを選んだという事実だけになります。
この結果をそのまま持ち帰れない条件
あなたの環境では違う結論になる条件を、先に並べておきます。
-
categoryを検索や集計に使っているなら話は変わります。 下流が無いのはこのシステムの都合にすぎません - n が小さく、
scoreを確認できたのは5件だけなので、統計的な主張は一切できません - コミットメッセージの「12件」と、私が確認できた「5件」は同じ実行の中の別の粒度です。12件全部の値は当時のログにしか残っていません
- 1環境・1モデル・1プロンプトの観察なので、他の条件で同じことが起きる根拠はありません
- プロンプトを直した後のデータが未蓄積で、「プロンプト改善が効いた」とはまだ言えません
- 損失①②を定量化していません。「承認コストを消費した」は事実ですが、時間や件数では測っていません
- 「プロンプト改善のほうが語彙定義より有効」とは言っていません。やったことを書いただけで、比較はしていないので
- OpenAI の記述は OpenAI の構造化出力機能についてのもので、私のシステムは別の方式で判定しているため、同じ性質が成り立つ保証はありません。引用したのは「値域の保証と内容の正しさは別」が公式にも明示されている、という点だけです
最後のひとつ手前は誤読されやすいので繰り返しておくと、この記事は「構造化は不要」とは言っていません。 順序の話をしています。
さて、category に下流が無いなら、選択肢は捨てるか下流を作るかの2つです。
私は下流を作ることにしました。記事のネタを検索したり集計したりしたい用途が、実際にあるので。ただそうすると前提が変わってしまって、下流ができた瞬間から構造化の水準を選ばないといけなくなります。
次はその選択肢を整理していきます。まず「オントロジー」という言葉が一次資料で何を指しているのかを4つに分けて、そのあと実装の選択肢を6段階に並べる予定です。