「残りが0より多い間、次の処理を頼む」というループを書いたことはありませんか。
自作の WordPress プラグイン Rapls AI Chatbot で、そのループが止まらなくなる不具合を 1.18.0 で直しました。原因は、どうやっても処理できない1件が残ると、残りの数が永遠に0にならないことでした。
直す前(1.17)と直した後(1.18)の判定を写して、120件の文書で数えた結果です。
| 状況 | 1.17 の往復回数 | 1.17 で埋め込めた数 | 1.18 の往復回数 | 1.18 で埋め込めた数 |
|---|---|---|---|---|
| 全部正常 | 3 | 120 | 3 | 120 |
| 10番目が大きすぎる | 200以上(打ち切り) | 0 | 3 | 119 |
| 110番目が大きすぎる | 200以上(打ち切り) | 100 | 3 | 119 |
| 10番目にテキストがない | 200以上(打ち切り) | 119 | 3 | 119 |
| レート制限が続く | 200以上(打ち切り) | 0 | 1 | 0 |
直し方は2つの条件の組み合わせでした。
| どこで | 何を足したか |
|---|---|
| サーバー側 | 何度やっても直らない失敗(上限超え・テキストがない)を、次の対象と残りの数から外す |
| ブラウザ側 | 1周して1件も進まなかったら、残りがあってもループを抜ける |
先に断っておくと、進まないループを止めるという考え方は新しいものではありません。ページ送りの API で、次のカーソルが前と同じなら止める、というのと同じです。ここで書くのは、それを入れていなかった自分の実装で何が起きていたかです。
止まる条件は、残りの数だけだと思っていた
ナレッジベースに登録した文書は、埋め込みの API に送って、ベクトルに変えてから検索に使います。管理画面のボタンを押すと、ブラウザが AJAX でサーバーに「次を頼む」と送り、サーバーは埋め込みが済んでいない文書を50件取り出して API に送り、何件できたかと、あと何件残っているかを返します。ブラウザは、残りが0より多ければもう一度頼みます。
1.17 までのブラウザ側は、こうでした。
if (response.data.remaining > 0) {
processBatch(source);
}
サーバー側で取り出す50件は、id の小さい順です。
SELECT id, title, content FROM ... WHERE embedding IS NULL AND is_active = 1 ORDER BY id ASC LIMIT 50
どちらも、それだけを見ればおかしくありません。残りがあれば続ける。済んでいないものを、古い順に取る。
問題は、埋め込めない文書が1件あるときです。
1件がバッチ全体を道連れにしていた
埋め込みのモデルには、1回に受け付ける長さの上限があります。上限を超える文書が1件でもバッチに入っていると、OpenAI はリクエスト全体を拒否します。同じバッチの残り49件も、巻き添えで埋め込まれません。
ここで、取り出し方が効いてきます。済んでいない文書を id の小さい順に50件なので、次の周も同じ50件が出てきます。同じ1件が入っているので、また全体が拒否されます。残りの数は減りません。ブラウザは、残りが0より多いので、また頼みます。
上限を超える文書が10番目にあると、1件も埋め込まれないまま、同じリクエストを繰り返し続けます。表の「10番目が大きすぎる」の行がそれです。110番目なら、最初の100件は通りますが、最後の20件で同じことが起きます。
課金される API キーなら、そのぶんの料金もかかります。しかも、画面には何のエラーも出ていませんでした。大きな文書が黙って0件で終わる、と利用者の方が原因まで調べて報告してくれたのが、直すきっかけでした。
テキストが取り出せない文書(文字のない PDF など)でも、ループは止まりません。こちらは空の文書を API に送らないので料金はかかりませんが、ブラウザとサーバーのあいだの往復が終わらなくなります。
サーバー側で、直らない失敗を外した
1.18.0 では、まず失敗の理由を5つに分けて記録するようにしました。大きすぎる(too_large)、テキストがない(no_text)、API キーの問題(auth)、レート制限(rate_limit)、その他(api_error)です。
このうち too_large(上限超え)と no_text は、文書の中身が変わらない限り、何度送っても結果が同じです。この2つが付いた文書は、次に取り出す対象から外し、残りの数からも外しました。編集されたら、また対象に戻ります。
$skip_ids = RAPLSAICH_Knowledge::get_permanent_embed_failure_ids();
$pending = RAPLSAICH_Knowledge::get_unembedded_entries(50, $skip_ids);
あわせて、バッチが拒否されたときは1件ずつ送り直すようにしました。10番目が上限を超える場合、最初の周で 1+50 回の API 呼び出しが走り、49件が通って、10番目だけに too_large が付きます。表で 1.18 の API 呼び出しが 53 回になっているのは、この送り直しの分です。
auth と rate_limit は、1件ずつ送り直しても全部同じ理由で失敗するので、一括で理由を付けて終わります。
ブラウザ側で、進まなかったら抜ける
サーバー側だけでは、まだ穴があります。レート制限のように、時間がたてば直るかもしれない失敗は、対象から外していません。外すと、あとで直ったときに拾えなくなるからです。
そこでブラウザ側に、もう1つ条件を足しました。
var madeProgress = (response.data.processed || 0) > 0;
if (response.data.remaining > 0 && madeProgress) {
processBatch(source);
}
1周して1件も埋め込めなかったら、残りがあっても止めます。レート制限が続く場合、1.18 は1回目の往復で止まりました。止まったあとは、埋め込めなかった件数を表示して、理由はナレッジベースの一覧で見られるようにしています。
2つの条件は、片方ずつでは足りませんでした。サーバー側だけだと、一時的な失敗でループします。ブラウザ側だけだと、直らない失敗が毎回同じ50件の先頭を占めて、その後ろに進めなくなります。外すことと、止まることの両方が要りました。
数え方
表の数字は、実際の API を叩いたものではありません。1.17 と 1.18 のサーバー側とブラウザ側の判定だけを JavaScript に写し、120件の文書で、ループが止まるまでの往復回数と API の呼び出し回数を数えたシミュレーションです。200回で打ち切っています。1.17 の「200以上」は、打ち切らなければ続いていたという意味です。
「残りの数」を止まる条件にしているループがあったら、その残りが減らない場合を一度考えてみてください。自分の場合は、利用者の方の報告を受けて直すまで、その場合を考えていませんでした。
シミュレーションのコード
// 1.18.0 前後のループを、サーバー側とブラウザ側の判定だけ写して数える
const CAP = 200;
function makeDocs(n, bad) { return Array.from({length:n},(_,i)=>({id:i+1, kind: bad[i+1]||'ok', emb:false})); }
function api(batch, s) { // 1回の埋め込みリクエスト
s.api++;
if (s.rateLimited) return {ok:false, why:'rate_limit'};
if (batch.some(d=>d.kind==='too_large')) return {ok:false, why:'too_large'}; // バッチ全体が拒否される
return {ok:true};
}
function serverOld(docs, s) {
const pending = docs.filter(d=>!d.emb).slice(0,50); // ORDER BY id LIMIT 50
if (!pending.length) return {processed:0, remaining:0};
const texts = pending.filter(d=>d.kind!=='no_text'); // 空文字は送らない
let processed=0;
if (texts.length && api(texts,s).ok) { texts.forEach(d=>d.emb=true); processed=texts.length; }
return {processed, remaining: docs.filter(d=>!d.emb).length};
}
function serverNew(docs, s) {
const skip = new Set(Object.keys(s.err).filter(id=>['too_large','no_text'].includes(s.err[id])).map(Number));
const pending = docs.filter(d=>!d.emb && !skip.has(d.id)).slice(0,50);
if (!pending.length) return {processed:0, remaining:0};
let processed=0;
const texts = pending.filter(d=>{ if (d.kind==='no_text'){ s.err[d.id]='no_text'; return false;} return true; });
if (texts.length) {
const r = api(texts,s);
if (r.ok) { texts.forEach(d=>d.emb=true); processed=texts.length; }
else if (r.why==='rate_limit') { texts.forEach(d=>s.err[d.id]='rate_limit'); } // 全件共通の失敗は一括で理由付け
else for (const d of texts) { const r1 = api([d],s); if (r1.ok){d.emb=true;processed++;} else s.err[d.id]=r1.why; } // 1件ずつ入れ直す
}
const skip2 = new Set(Object.keys(s.err).filter(id=>['too_large','no_text'].includes(s.err[id])).map(Number));
return {processed, remaining: docs.filter(d=>!d.emb && !skip2.has(d.id)).length};
}
function run(server, clientNew, docs, rateLimited=false) {
const s={api:0, ajax:0, err:{}, rateLimited};
while (true) {
if (s.ajax >= CAP) return {...s, stopped:false};
s.ajax++;
const r = server(docs, s);
const cont = clientNew ? (r.remaining>0 && r.processed>0) : r.remaining>0;
if (!cont) return {...s, stopped:true};
}
}
const cases = [
['全部正常', 120, {}, false],
['id 10 が大きすぎる', 120, {10:'too_large'}, false],
['id 110 が大きすぎる', 120, {110:'too_large'}, false],
['id 10 がテキストなし', 120, {10:'no_text'}, false],
['レート制限が続く', 120, {}, true],
];
for (const [name,n,bad,rl] of cases) {
for (const [label, srv, cli] of [['1.17', serverOld, false], ['1.18', serverNew, true]]) {
const docs = makeDocs(n,bad); const r = run(srv, cli, docs, rl);
console.log(`${name.padEnd(14)} ${label} AJAX=${String(r.ajax).padStart(3)}${r.stopped?'':'(打ち切り)'} API=${String(r.api).padStart(3)} 埋め込み済み=${docs.filter(d=>d.emb).length}/${n}`);
}
}
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.com/