お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」では、AIが記事の要約を書いています。9月に、要約へ根拠の無い年が混ざる問題を、保存前の照合で止める仕組みを入れました。今回は、その仕組み自体が原因になって、1件の記事のために分析の処理がほぼ止まってしまった不具合と、その直し方を書きます。
何が起きていたか
1日の要約件数が平日の5分の1ほどに落ちていた
DevPickのバックエンドでは、30分ごとに動く定期処理が、まだ分析していない記事をLLMに渡して要約・分類・トピック付けをまとめて行います。LLMはGroqの無料枠で使えるモデル(qwen/qwen3.8-27b)で、1日に使えるトークン数は直近24時間の移動枠で200,000です。
気づいたきっかけは、分析が遅れている原因を調べていたことでした。この頃は分析だけで日次枠の大半を使っていて、LLMのモデルや課金の構成を見直すつもりで本番の状況を調べていました。その途中で、次の数字が目に入りました。
- 2026年10月23日に取り込んだ記事(日本時間で集計)のうち、LLMで要約できたのは21件。平日はふだん90〜120件
- まだ分析していない記事が101件に増えていた
新着の記事が届かなくなったわけではありません。届いた記事を分析する側が、ほとんど進んでいませんでした。
ログには同じ記事の棄却と作り直しが並んでいた
分析のログを見ると、1件の記事が繰り返し出てきました。The Vergeの「Data centers could erase decades of air quality gains」という記事で、取り込んだのは10月22日の8時27分(日本時間)です。概要には「back to levels not seen since the 2000s in Texas」という一文がありました。
10月23日13時45分から22時50分(日本時間)までの約10時間で、関係するログの件数は次のとおりでした。
| ログの内容 | 件数 |
|---|---|
| 要約に根拠の無い年「2000」がある、として棄却 | 18回 |
| 棄却した記事の要約の作り直しを依頼 | 18回 |
| 作り直しの依頼がレート制限(429)に当たった | 13回 |
| この記事を未分析のまま残す | 5回 |
| 分析した件数として2件を記録 | 5回 |
10時間で同じ記事の作り直しを18回頼んでいます。30分ごとの処理が、ほぼ毎回この記事から始まっていたことになります。
IT女子 アラ美「2000年代」は年と数え、「2000s」は数えなかった
要約と元記事を同じ規則で照合する仕組みだった
この照合は、9月に入れたものです。AI要約が元記事に無い年を自分で補い、これから始まる変更を2年前の出来事として伝えていた問題がありました(経緯はAI要約が、2026年の記事を「2024年から変更」と伝えていた話に書いています)。そこで、要約と元記事のタイトル・概要から同じ規則で西暦を抜き出し、要約にだけある年があれば棄却するようにしました。
今回の記事では、LLMは英語の「since the 2000s」を「2000年代以来」と日本語で要約していました。訳としては正しい要約です。ところが照合の規則は、両者を同じ物差しで測れていませんでした。
日本語の「年」は拾い、英語の「s」付きは拾わない正規表現だった
年を抜き出す正規表現は、次の2通りの書き方を年として扱っていました。
- 日本語の年:数字の直後に「年」が続く(「2000年代」の「2000」もここで拾われる)
- 英語文中の独立した年:前後に英数字が続かない4桁の数字
英語の書き方には、「GPT-2024.1」のような版数や「2024億」のような数量を年と取り違えないよう、後ろに英数字が続くものは除く条件が付いていました。この条件のせいで、「2000s」は後ろに「s」が続くため年として数えられません。
year_pattern = (
r"(?<!\d)(20[0-9]{2})(?=年|[-/]\d{1,2}[-/]\d{1,2}(?!\d))"
r"|(?<![\w.])(20[0-9]{2})(?!\w|\.\d)"
)
結果として、要約側からは「2000」が抜き出され、元記事側からは何も抜き出されません。照合は「要約にだけ2000年がある」と判断し、正しい要約を根拠の無い年として棄却していました。9月の記事の最後で「比べる両側を同じ物差しで測れているかを疑う」と書いたのに、日本語と英語で物差しがずれていた形です。



1件の誤棄却が日次枠を食い潰した仕組み
誤った棄却が1件あるだけなら、その記事の要約が付かないだけで済むはずです。日次枠まで使い切ったのは、別々の目的で入れた仕組みが噛み合ってしまったからでした。
棄却された記事は、毎回いちばん先に選ばれていた
9月の照合では、作り直しても根拠の無い年が残る記事は保存せず、未分析のまま残すことにしていました。誤った年を含む要約を出すくらいなら、次の回にもう一度分析してもらう、という判断です。
一方でDevPickには、取り残された未分析記事を回収する枠があります。新しい記事を優先して分析していると、一度遅れた古い記事が新着に追い越され続け、いつまでも分析されません。それを防ぐため、毎回の分析枠の一部を、一定時間より前に取り込んだ未分析記事のために確保し、古い順に選んでいます。
この2つが重なると、次の流れが30分ごとに繰り返されます。
- 取り残し回収の枠が、最も古い未分析記事としてこの記事を選ぶ
- この記事を含む2件の分析をLLMに頼む(約2,900トークン)
- 照合でこの記事の要約が棄却され、作り直しを頼む(約1,900トークン)
- 作り直しでも「2000年代」と書かれ、また棄却されて未分析のまま残る
- 次の回も、この記事が最も古い未分析記事として選ばれる
照合が誤っている限り、何度作り直しても結果は同じです。それなのに回数の上限が無かったので、この記事は10月22日から分析され続けていました。
作り直しが429で失敗すると、問題の無い記事まで捨てていた
もう1つ、浪費を大きくしていた動きがあります。作り直しの依頼がレート制限(429)に当たると、そのバッチ全体を保存せずに中断していました。同じバッチのもう1件は、最初の分析で何の問題も無かったのに捨てられ、次の回にまた最初から分析し直すことになります。
429のときに処理を止めること自体は、以前に入れた対策です。429を握りつぶすと、別の処理と共有している「いつまで待つか」の記録が残らず、上限に張り付いたままLLMを叩き続けてしまうからです(その経緯はGroq無料枠を取り合い、英語タイトルの翻訳が1,412件進まなかった話に書いています)。止めること自体は正しかったのですが、問題の無かった記事まで道連れにしていました。
さらに、分析の件数を記録するログは、保存したかどうかに関係なくバッチの件数を出していました。この記事を未分析のまま残した回でも「2件を分析」と記録されていたので、ログの件数だけを見ると、分析が進んでいるように読める状態でした。



直した3つのこと
修正は、次の3つを1つの変更にまとめて、同じ日(10月23日)に入れました。順番に足したものではありません。前の章で書いた原因のそれぞれに、1つずつ対応しています。
英語の年代表記を、その年の根拠として数える
1つ目は、直接の原因だった正規表現です。英語の書き方に、末尾の「s」「’s」「’s」を許す部分を足しました。これで「2000s」「2000’s」「2000’s」は、どれも「2000」年の根拠として数えられます。
year_pattern = (
r"(?<!\d)(20[0-9]{2})(?=年|[-/]\d{1,2}[-/]\d{1,2}(?!\d))"
r"|(?<![\w.])(20[0-9]{2})(?:['’]?s)?(?!\w|\.\d)"
)
「s」の後ろにさらに英数字が続く「2000sx」のような文字列は、これまでどおり年として数えません。テストには、3通りの年代表記に加えて、この境界と、根拠と関係の無い年代が要約に出てくる場合も足しています。
作り直しが429で失敗しても、問題の無かった記事は先に保存する
2つ目は、作り直しが429に当たったときの扱いです。最初の分析で棄却されなかった記事は保存し、そのあとで429を呼び出し元へ伝えるように変えました。止まる動き自体は変えていないので、共有している「いつまで待つか」の記録はこれまでどおり残ります。未分析のまま次の回へ回すのは、作り直しが必要だった記事だけです。
作り直しても年が残る記事は、要約を捨てて分析を打ち切る
3つ目が、上限の無い繰り返しを止める変更です。作り直しの応答を受け取れたのに、それでも根拠の無い年が残る記事は、もう一度頼んでも同じ誤りを繰り返しうると判断します。その記事は、日本語・英語の要約を保存せず、分類とトピックだけを保存して分析済みにします。
単純化すると、保存前の判定は次の形です。
if unsupported_summary_years(article, summary_texts):
if not retry_answered:
# 作り直しを一時的な失敗で試せなかった。次の回で作り直す
leave_unanalyzed(article)
continue
# 作り直しても誤った年が残った。要約は保存せず、分類・トピックだけ保存して打ち切る
drop_summary = True
ここで区別したのは、作り直しを「試せなかった」のか、「試したが駄目だった」のかです。通信の失敗のような一時的な理由で作り直せなかった記事は、次の回に試す意味があるので、これまでどおり未分析のまま残します。試して駄目だった記事だけを打ち切ります。
要約の無い記事は、記事詳細ページを検索に公開する条件を満たさないので、検索には載りません。誤った年を出さないという9月の方針は、そのまま守っています。あわせて、分析件数のログも実際に保存した件数を出すように直しました。



修正後に確かめたこと・まだ測れていないこと
問題の記事は、修正のマージ直後に要約付きで分析された
修正をマージしたのは、10月23日の23時22分(日本時間)です。マージ前のテストでは、バックエンドの7,682件がすべて通り、マージ前の自動レビューでも指摘はありませんでした。
本番のAPIで問題の記事を見ると、最初に分析された時刻は23時23分で、要約付きで保存されていました。保存された要約は次のとおりです。
テキサス州で建設が進む新データセンター群の電力需要増により、ガス火力発電の活用が増加し、2000年代以来の深刻な大気汚染レベルに逆戻りする懸念が指摘されている
「2000年代」は元記事の「2000s」を根拠として認められ、要約を捨てる3つ目の経路は通っていません。記事詳細ページも検索公開の状態(index)になっています。10月22日8時27分に取り込んでから、約39時間たってようやく分析が終わったことになります。
日次枠にどれだけ余裕が戻ったかは、まだ測っていない
一方で、分析の日次枠にどれだけ余裕が戻ったかは、この記事を書いている10月24日の時点ではまだ測っていません。
この不具合は10月22日から続いていました。それ以前から測っていた「分析だけで日次枠の約95%を使っている」という数字にも、この浪費が含まれていた可能性があります。どれだけ含まれていたかは、修正後の消費と比べるまで分かりません。
測り直しは、修正のデプロイから24時間以上たった10月25日以降に行う予定です。休日は新着が少ないので、平日(10月26日以降)を必ず含めて、次の点を見ます。
- 日別の要約件数と、未分析記事の件数が減っているか
- 分析モデルのレート制限(429)がどれだけ出ているか
- 同じ記事の棄却や、要約を捨てて打ち切った記事が繰り返し出ていないか
平日も429が続いて未分析記事が溜まるようなら、分析モデルを従量課金に切り替えること(試算では月約7.5ドル)を第一候補として検討します。無料枠のままで足りるかどうかは、この測り直しで決めます。



まとめ
DevPickのAI要約の照合が、日本語の「2000年代」は年と数え、英語の「2000s」は数えない規則になっていました。そのため正しい要約が根拠の無い年として棄却され、棄却された記事は取り残し回収の枠で毎回いちばん先に選ばれて、分析と作り直しを上限なく繰り返していました。作り直しが429に当たるたびに問題の無い記事まで捨てていたことも、浪費を大きくしていました。
修正は1つの変更にまとめて入れました。英語の年代表記を年の根拠として数えること、429で止まる前に問題の無かった記事を保存すること、作り直しても年が残る記事は要約だけを捨てて打ち切ることの3つです。問題の記事はマージの直後に要約付きで分析されました。日次枠にどれだけ余裕が戻ったかは、10月25日以降に測り直します。
今の私の結論は、誤りを保存しない側に倒す検証には、打ち切り条件も一緒に用意する、ということです。「保存しないで次の回にもう一度」という判断は、検証が正しいときには安全です。でも検証そのものが誤っていると、同じ失敗を何度でも繰り返す輪になります。そして、その輪を回すのは、取り残しを必ず拾い直すという別の安全装置でした。止める仕組みを足すときは、止めた後に何が起きるか、何回まで繰り返してよいかまで決めておきたいと考えています。












