Featured image of post コードディスパッチャー【Request Coalescing】「同じ質問に、何度も課金していました」〜同じ行き先には、一本で足りる〜

コードディスパッチャー【Request Coalescing】「同じ質問に、何度も課金していました」〜同じ行き先には、一本で足りる〜

セール初日だけ費用が跳ね上がった。同じ質問が同時に何十組も届き、それぞれ別々にAIへ問い合わせていた。正規化と同時到着の束ね(Request Coalescing)を標準ライブラリだけで実装し確かめます。

第1幕: 持ち込み ── セール、すごく評判が良かったんです

前の夜からまだ一日しか経っていない。記録机の新しい綴じ込みには、まだ数ページぶんの記録しか無い。角の立ったままの表紙を、私は特に感慨も持たずに一瞥した。いつもどおり、表示盤の前に腰を下ろす。サカキも、盤を見つめる姿勢を崩さない。今夜はまだ、線が一本も乱れていなかった。

ドアが開いた。

「夜分にすみません、こんな時間に」

アサクラさんの声には、疲れではなく、急く調子があった。サカキが椅子を勧める。

「座ってください。時間はありますか」

「あまり、ないです。でも、今夜中に済ませないと」。アサクラさんが早口で続けた。「明日の朝一番で、経理への説明会があって……それまでに、言葉にしておきたくて」

コートを脱ぐ間も惜しむような早口だった。指令所の落ち着いた空気と、アサクラさんの息の上がり方の落差が、私には少し珍しく感じられた。

腰を下ろすと、アサクラさんは前のめりに話し始めた。

「セール、すごく評判が良かったんです。売上が、通常の三倍になって」

声に張りがある。誇らしげに、まず成果から話す人だった。私はその調子のまま、「来訪」の欄を書き始めた。これまでとは違う形の、期限に追われての来訪だった。欄の頭に、その旨を短く記す。

私の独白:これまでの相談者は、みな何かに困った顔で入ってきた。アサクラさんの最初の一言は、報告というより、自慢に近かった。相談の入り方が、いつもと違う。

サカキ「それで……何がご相談ですか」

アサクラさんの声から、少しだけ張りが抜けた。

「経理から、指摘があって。セール初日だけ、外部APIの費用が、通常の三倍から四倍になっていると。理由を、説明資料にまとめてほしいと言われました」

私の独白:売上も、費用も、同じ三倍。数字だけ聞けば、噛み合っているようにも見えた。それの何が問題なのか、この時点ではまだ分からなかった。

サカキ「三倍から四倍というのは、セール期間全体で、ですか」

「いえ」。アサクラさんが少し首を振る。「セール初日だけです。二日目以降は、いつもと変わらない水準に戻っています」

サカキ「初日だけ」

「はい。経理の人は『初日だけ突出している』と言っていました。正確には、初日の午後です」

サカキ「まだ、何も分かりません。その日、何があったのかを、これから一つずつ確かめましょう」

アサクラさんが小さくうなずいた。私の独白:アサクラさんは、まだ困ってはいなかった。困る前の、説明を求められている段階だった。

第2幕: 照合 ── 同じ答えを、四回

アサクラさんが鞄から、印刷したコードの束を取り出した。「知人のエンジニアが、コードを見せてほしいと言われたときのために、と用意してくれていて……私は、質問集の追加とボタンの設定だけ、担当しています」

紙をサカキの前に置く。私も横から覗き込んだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from dataclasses import dataclass


@dataclass
class QAResponder:
    client: object

    async def answer(self, question: str, product_id: str) -> str:
        reply = await self.client.ask(question, product_id)
        return reply["body"]

サカキ「クイックリプライのボタンを増やしたのは、良い判断です。迷わず質問できる導線を作った、ということですから」

アサクラさんが少し表情を緩めた。「そう言っていただけると……。でも、それがまさか、費用に関係するとは思っていませんでした」

「半年前は、自由入力だけだったんです」。アサクラさんが続けた。「お客さまが何を打ち込むか分からなくて、答えられない質問も多くて。ボタンにすれば、よくある質問には、確実に答えられる。そう思って、セール前に増やしました」

サカキ「実際、答えられなかった質問は、減ったはずです」

「はい、そこは減りました。だから、良い変更だったと、今でも思っています」

アサクラさんの声に、また少しだけ張りが戻った。良い判断だったことと、費用が跳ね上がったことは、同じ変更の中で両方とも本当だった。私はその両方を、続けて書き取った。

サカキ「これは、どういう仕組みですか」

「質問を受け取ったら、そのまま外部のAIへ渡して、答えをもらってくる。それだけです」。アサクラさんが説明を続けた。「よくある質問はボタンにしていて、タップするだけで送れます。もちろん、自由に入力してもらってもいいんですが」

私はコードを目で追った。質問が届くたびに、何の準備も無く、外へ問い合わせている。同じ質問が二度届いても、それを見分ける仕組みがどこにも無い。

サカキ「セール初日の、午後2時台のアクセスは、普段と比べてどうでしたか」

アサクラさんが手元の資料を見ながら答えた。「同時に見ているお客さまの数で言うと、いつもの十倍近くまで増えていたと思います。人気の商品は、特に」

サカキ「その中の、何割くらいが、同じような質問をしていたと思いますか」

「そこまでは……分かりません。個別のログは見ていないので」

サカキ「では、その時間帯のやり取りを、時刻順に並べてみましょう」

私はログを書き写した。同じ商品への質問が、二秒のあいだに固まって並んでいる。

1
2
3
4
14:02:03  「在庫はありますか?」(商品: 折りたたみ傘A)
14:02:03  「在庫はありますか?」(商品: 折りたたみ傘A)
14:02:04  「 在庫はありますか?」(商品: 折りたたみ傘A)
14:02:04  「在庫はありますか? 」(商品: 折りたたみ傘A)

四行とも、表記はわずかに違う。疑問符が全角だったり半角だったり、前後に空白があったりなかったり。けれど、内容は同じ質問だった。

私の独白:同じことを二度言われても、私は記録の欄を二つに増やしたりしない。一度書けば、それで済む。……このシステムは、律儀に四回とも、別々に処理していた。

サカキが一言添えた。「同じ質問でも、外部の返事は毎回微妙に違う、と以前お話ししました。四回問い合わせれば、四通りの答えがあり得たということです」

アサクラさんが目を見開いた。「同じ質問なのに、答えが違うことも……」

サカキ「今回、答えの中身までは崩れていませんでした。ですが、崩れる可能性は、常にありました」

私は続けて、その一分間に起きていたことを数える。同じ形の重なりが、この一分だけで十七組あった。四行が一組なら、十七組は六十八件。一件ずつは小さな費用でも、六十八件ぶんが積み上がれば、決して小さくはない。

アサクラさんが数字を聞いて、少し声を落とした。「一分だけで、それだけの重なりが……。セール初日の午後は、何時間も、あの状態だったということですよね」

サカキ「はい。ピークの時間帯が長ければ長いほど、重なりの総量も増えます」

私はその数字を、「発見された重なり」として欄に書いた。原因はまだ、名前を持っていなかった。

サカキ「これは、四人分の仕事ではありません。一人分の仕事を、四回しているだけです」

アサクラさんが言葉を失った。「……そんなに、単純なことだったんですか」

サカキ「では、シミュレータで実際に動かしてみます」。流す前に、今夜の異常の範囲を一つだけ区切っておく。「同時に同じ質問が来たとき、それぞれ別に処理されている——それを、今夜の異常ということにして、いいですか」

アサクラさん「はい、間違いなく」

サカキ「では、それだけを今夜の異常とします。ほかは決めていません」

四件ぶんの台本を、まったく同じタイミングで投げ込む。表示盤の隅で、処理を示す点が四つ、同時に動き出した。

……四つとも、別々に動いた。同じ質問なのに、外部への問い合わせは四回。表示盤の点は、それぞれ独立に進み、独立に止まった。

アサクラさんが、力の抜けたような声で言った。「これが、セールの初日に、何十組も起きていたということですね」

私の独白:原因は、壊れていたことではなかった。ちゃんと動いていたことが、そのまま無駄だった。

第3幕: 指令の一手 ── 同じ行き先には、一本で足りる

アサクラさん「これは、どうすれば防げたんでしょうか」

サカキ「同じ質問が重なって届いたとき、最初の一件だけを実際に処理して、残りはその結果を待って、同じ答えを受け取る。Request Coalescing(リクエスト結合)——重なった問い合わせを一本にまとめる仕組みです」

続けて、比喩で言い換えた。「同じ行き先の客に、別々の列車を仕立てる必要はありません」

サカキが続ける。「同じ区間へ、同じ列車を二本も三本も、続けて走らせている状態です。鉄道では、それを『続行運転』と呼びます。今夜やるのは、その続行運転の解消——一本にまとめる、というだけのことです」

運行の言葉に、また一つ、正式な名前がついた。私はそれを記録の欄に書き添えた。

サカキがコードを示した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import asyncio
import unicodedata
from collections.abc import Callable
from dataclasses import dataclass, field


def normalize_query(question: str, product_id: str, model: str) -> str:
    normalized = unicodedata.normalize("NFKC", question).strip()
    normalized = " ".join(normalized.split()).casefold()
    return f"{model}:{product_id}:{normalized}"


@dataclass
class CacheEntry:
    answer: str
    expires_at: float


@dataclass
class RequestCoalescer:
    client: object
    model: str
    ttl_seconds: float
    clock: Callable[[], float]
    _cache: dict[str, CacheEntry] = field(default_factory=dict)
    _inflight: dict[str, "asyncio.Future[str]"] = field(default_factory=dict)

    async def answer(self, question: str, product_id: str) -> str:
        key = normalize_query(question, product_id, self.model)

        cached = self._cache.get(key)
        if cached is not None and cached.expires_at > self.clock():
            return cached.answer

        existing = self._inflight.get(key)
        if existing is not None:
            return await existing

        future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
        self._inflight[key] = future
        try:
            reply = await self.client.ask(question, product_id)
            answer = reply["body"]
        except Exception as exc:
            future.set_exception(exc)
            raise
        else:
            self._cache[key] = CacheEntry(
                answer=answer, expires_at=self.clock() + self.ttl_seconds
            )
            future.set_result(answer)
            return answer
        finally:
            del self._inflight[key]

サカキ「まず、表記のゆれを揃えます。全角と半角、余分な空白——同じ質問なのに、違う文字列に見えているだけのものを、同じ形にそろえます」

アサクラさん「似ている質問も、まとめてもらえるんですか」

サカキ「いいえ。まとめるのは、同じ質問だけです。似ている質問まで、まとめてしまうと、違う答えを返すべき客に、同じ答えを返しかねません」

私の独白:揃えるのは表記だけ。意味までは、揃えない。

サカキ「今、答えを作っている最中の質問と、同じ質問が来たら」。コードの_inflightを指す。「新しく問い合わせるのではなく、今作っている最中の答えを、一緒に待ってもらいます」

アサクラさん「待たせる、ということですか」

サカキ「待たせます。ただし、一回きりです。先発の列車が、もう出ている——後から来た客は、新しい列車を仕立てず、その列車に乗ればいい」

続けて、技術語で言い直した。「コードで言えば、_inflightという帳面に、今処理中のキーを書いておきます。同じキーの問い合わせが来たら、帳面にあるFuture——処理の途中経過を指す印——を、一緒にawaitするだけです」

アサクラさん「確認してから帳面に書き込むまでのあいだに、別の同じ質問が割り込んできたりは……」

サカキ「しません。確認と書き込みのあいだに、await——待つ動作——を挟んでいないからです。待つ動作を挟まない限り、ほかの問い合わせに順番が回ることはありません。だから、確認から書き込みまでは、途切れない一つの動作として扱えます」

アサクラさん「その印は、誰が、いつ、答えを書き込むんですか」

サカキ「最初に問い合わせた側です。外部のAIから答えが返ってきた瞬間、そのFutureに答えを書き込みます。書き込まれた瞬間、awaitして待っていた残り三件も、同時に答えを受け取ります」

私の独白:一人が代表して並び、答えを持ち帰って、待っていた全員に配る。窓口の仕事に、少し似ている。

アサクラさん「代表の一件が、失敗したら」

サカキ「そのときは、待っていた三件にも、同じ失敗が伝わります。一人の失敗が、四人分の失敗になる——まとめたことの、裏返しです」

得をする話の裏に、道連れになる話がある。私は両方を、記録の同じ段に並べて書いた。

私はその一部始終を、記録の余白に描いてみた。四本バラバラに走っていた線が、どこで一本に集まり、集まった先から、良い知らせも悪い知らせも、同じ本数だけ元の場所へ戻っていくところを。

同時到着した4件の問い合わせのBefore/After対比図。Beforeは4件がそれぞれ独立に外部AIへ問い合わせ、Afterは先発の1件だけが外部AIへ問い合わせ残り3件はそのFutureをawaitして待ち、応答が成功すれば4件全員へ同じ答えが配布され、失敗すれば4件全員へ同じ失敗が伝わることを示す図

集まる場所は一つしかない。だから、そこから伸びる線の本数も、良い知らせのときと悪い知らせのときで、変わりはしない。

アサクラさん「答えを控えておくだけでは、駄目だったんですか。四件とも、控えを確認すれば」

サカキ「控え——_cache——を確認するのは、答えが出そろってからです。四件が、まったく同じ瞬間に届いたとき、最初の一件すら、まだ答えを受け取っていません。控えの棚は、まだ空のままです」

アサクラさんが眉を寄せた。「四件とも、空の棚を見て、じゃあ自分で問い合わせよう、と動いてしまう」

サカキ「はい。控えだけでは、まだ答えが無い瞬間の重なりを防げません。だから、控えとは別に、_inflight——今まさに答えを作っている最中のキーを記録する帳面——が要ります」

私はここで、二つの装置の役割の違いを整理して書いた。_cacheは、過去に出した答えを再利用するための棚。_inflightは、今この瞬間に走っている処理を、他の誰かに知らせるための帳面。片方だけでは、今回のような重なりは防げない。

二段の関門を、そのまま図にした。

RequestCoalescerのanswer()が正規化したキーで_cacheと_inflightの二段を確認する判定フロー図。_cacheに有効な答えがあればそのまま返し、無ければ_inflightに同じキーのFutureがあるか確認し、あれば既存のFutureをawaitして一緒に待ち、無ければ自分がFutureを作って_inflightに登録してから外部AIへ問い合わせ、応答が返ったら_cacheへの書き込みとFutureへの結果セットを行い最後に_inflightから削除することを示す図

棚を見るだけでは、空かどうかしか分からない。帳面まで見て初めて、今まさに誰かが列に並んでいることが分かる。

アサクラさん「これで、もう二重には払わずに済むんですね」

サカキ「この指令所——一つのプロセスの中だけでは、そうです」。少し間を置く。「もし別の窓口が同時に同じ質問を受けていたら、そちらはそちらで、別に処理してしまいます。_cache_inflightも、このプロセスのメモリの中にしか無いからです」

アサクラさん「うちは、今のところ一つの窓口だけです」

サカキ「今夜の話が効くのは、そこまでです。窓口が増えたときは、また別の設計が要ります」

私はその線引きを、記録の欄にはみ出さない大きさで書き留めた。

サカキがコードのCacheEntryを指す。「答えは、ずっと使い回しません。一定の時間が経てば、また新しく尋ねます。在庫の状況は、変わりますから」

続けて、配線のコードを示す。

1
2
3
4
def build_responder(
    client, *, model: str, ttl_seconds: float, clock: Callable[[], float]
) -> RequestCoalescer:
    return RequestCoalescer(client=client, model=model, ttl_seconds=ttl_seconds, clock=clock)

サカキ「答えを求める側の書き方は、何一つ変わりません。変わるのは、有効期限をどれだけにするか、という組み立ての中身だけです」

アサクラさんがうなずいた。「今回は、六十秒にする、とか」

サカキ「はい、それも決めることの一つです。長すぎれば古い答えを渡し続け、短すぎれば束ねる意味が薄れます」

アサクラさん「サーバーの台数を増やして、力ずくで捌く、という手も考えたんですが」

サカキ「それでも、四回ぶんの費用は残ります。台数を増やせば、待たされる時間は短くなるかもしれません。でも、同じ質問に四回課金している、という中身は変わりません」

私の独白:力を足すことと、無駄を無くすことは、別の話だった。アサクラさんが最初に考えた手は、症状を和らげるだけで、原因には触れていなかった。

第4幕: シミュレータと引き渡し ── 一本にまとめても、届く先は変わらない

サカキが、もう一度シミュレータへ台本を通す。今度は八本、結果を私が読み上げた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
async def test_同時到着した同じ問い合わせはAPIを一度しか呼ばない(self):
    client = ScriptedClient([{"body": "在庫があります"}])
    coalescer = RequestCoalescer(
        client=client, model="gpt-x", ttl_seconds=60.0, clock=FakeClock()
    )

    results = await asyncio.gather(
        *(coalescer.answer(q, "product-A") for q in SAME_QUESTION_VARIANTS)
    )

    self.assertEqual(client.call_count, 1)
    self.assertEqual(results, ["在庫があります"] * 4)

表記の違う四件を同時に投げても、外部への問い合わせは一回だけだった。四件全員が、同じ答えを受け取っている。

もう一本、別の商品への質問を同時に投げてみる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
async def test_異なる商品への問い合わせは別々にAPIを呼ぶ(self):
    client = ScriptedClient(
        [{"body": "商品Aの在庫があります"}, {"body": "商品Bの在庫があります"}]
    )
    coalescer = RequestCoalescer(
        client=client, model="gpt-x", ttl_seconds=60.0, clock=FakeClock()
    )

    results = await asyncio.gather(
        coalescer.answer("在庫はありますか?", "product-A"),
        coalescer.answer("在庫はありますか?", "product-B"),
    )

    self.assertEqual(client.call_count, 2)
    self.assertEqual(results, ["商品Aの在庫があります", "商品Bの在庫があります"])

同じ質問文でも、商品が違えば、外部への問い合わせは二回に分かれた。アサクラさんが少しほっとした様子で言う。「商品ごとに、ちゃんと別に答えてくれるんですね。まとめすぎて、違う商品の在庫を答えてしまう、みたいな心配はしていたので」

サカキ「キーには、商品IDも含めています。モデルを切り替えることもあるので、モデル名も一緒に含めています。同じ商品、同じモデルへの、同じ質問だけをまとめます」

もう一本、有効期限を確かめる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
async def test_有効期限が切れると再びAPIを呼ぶ(self):
    client = ScriptedClient(
        [{"body": "在庫があります"}, {"body": "在庫がありません"}]
    )
    clock = FakeClock()
    coalescer = RequestCoalescer(
        client=client, model="gpt-x", ttl_seconds=60.0, clock=clock
    )

    await coalescer.answer("在庫はありますか?", "product-A")
    clock.advance(31)
    clock.advance(30)  # 合計61秒経過。TTL(60秒)を超える。
    await coalescer.answer("在庫はありますか?", "product-A")

    self.assertEqual(client.call_count, 2)

有効期限は六十秒。あわせて六十一秒が経ったところで、控えは期限切れとなり、もう一度外部へ問い合わせに行った。控えの回答にも、期限がある。

サカキが、保証する範囲を二つに分けて告げる。

保証すること。同時に届いた同一の質問を一本にまとめ、一度だけ処理すること。表記のゆれを吸収して同じ質問として扱うこと。有効期限内は、以前の答えをそのまま返すこと。重なりが無いときは、これまでと同じ結果が返ること。

保証しないこと。似ているが表記の違う質問をまとめること。複数のプロセス・複数のマシンにまたがる重複の防止。代表の1件が失敗したときに、相乗りしていた全員へ同じ失敗が伝わらないようにすること。

サカキは、それ以上は付け加えなかった。窓口が一つだけの前提は、コードを示したときに、もう言い渡してある。私は保証の一覧を、そのまま欄に書き写した。

アサクラさんが帰り支度をする横で、私は記録を書き終えようとしていた。サカキが不意に言う。

「トウヤ、今日から、この欄も足してください」

指示された欄を、様式に書き加える。理由は聞かなかった。サカキも、それ以上は言わなかった。

アサクラさんが立ち上がり、コートを羽織った。窓の外は、まだ暗い。印刷したコードの束を鞄にしまう手つきは、来たときより落ち着いていた。

荷物をまとめながら、ふと足を止める。

「私、最初に、『セールは成功でした』って言いましたよね」

サカキは何も言わず、続きを待つ。

「成功はしていました。ただ——同じ分だけ、無駄も一緒に走っていたんです。それを、成功だとだけ思っていました」

サカキが一言だけ返した。「その言い方の方が、明日の説明会には、合うと思います」

アサクラさんが小さく頭を下げた。「……ありがとうございました。これで、資料が書けます」

誇らしさが、静かな納得に変わった調子だった。足音が遠ざかる。

足音が完全に消えてから、私はもう一度、記録の最後の行を見返した。今夜渡せたのは、まとめ方だけだった。成功と無駄が、同じ数字の中に同居していたことに、気づけただけでも、違うのだろう。


🚦 本日の指令記録(Dispatch Log)

  • 指令の定石(パターン名): Request Coalescing(リクエスト結合)── 同時に届いた同一の問い合わせのうち最初の1件だけを実際に処理し、残りはその結果を待って同じ答えを受け取る仕組み
  • 申告された症状: 「セール初日だけ、外部APIの利用費用が通常の3〜4倍になっていると経理から指摘された」。当事者は当初、セールの成功だけを実感しており困ってはいなかった。実際には、同じ内容の問い合わせが表記ゆれを含みながら同時に何十組も重なり、それぞれ別々にAIへ問い合わせていた
  • 今の運行計画の問題: セールにあわせてクイックリプライボタンを追加した判断自体は合理的だった。ただし、同じ質問が重なって届いたときにまとめる仕組みが、どこにも無かった
  • 打った一手: 質問の表記ゆれ(空白・全角/半角)を正規化してキーを組み立て、同じキーの問い合わせが進行中なら新たに問い合わせず、その結果を一緒に待つRequestCoalescerを導入。有効期限つきで答えを保持し、期限内の再訪問はAIへ問い合わせない
  • 配線した場所: 起動時の組み立て(build_responder)。有効期限をどれだけにするかは、この組み立ての中身だけを差し替えれば済む。呼び出す側のコードは変更していない
  • 保証しないこと: 似ているが表記の違う質問をまとめることは保証しない。複数のプロセス・複数のマシンにまたがる重複は防げない。代表の1件が失敗したときに、相乗りしていた全員へ同じ失敗が伝わらないようにすることは保証しない
  • シミュレータ結果: 8本。Beforeが同時到着の同じ質問をそのたびに別々に処理してしまうこと、Afterが一本にまとめて全員に同じ答えを配ること、有効期限内はAIへ問い合わせないことを確かめた
  • 次の当直への申し送り: 今夜まとめられたのは、この窓口の中だけ。窓口が増えたときの設計は、まだ無い

アクセスが集中する時間帯だけ外部APIの費用が跳ね上がるシステムをお持ちなら、表記の正規化と同時到着の束ねという構成を、これまでも組んでいます。

相談窓口はこちら

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。