Featured image of post コードディスパッチャー【Exception Translation】「自分の記録の付け方を疑っていました」〜200が返っても、契約を満たさなければ失敗として扱い直す〜

コードディスパッチャー【Exception Translation】「自分の記録の付け方を疑っていました」〜200が返っても、契約を満たさなければ失敗として扱い直す〜

LLM APIは200 OKのまま契約違反(空・打ち切り・拒否)を返すことがある。例外が上がらず成功として流れる静かな失敗を、独自例外への変換とraise...from eの例外チェーンで検出し直す設計をPython標準ライブラリだけで確かめます。

深夜二時に近かった。

当直交代を挟んで、指令所は静かだった。空調が低く唸り、壁一面の運行表示盤には緑の点がいくつも灯って、どれも変わらない速さで動いている。

日付、当直者名、天候。私は記録机でペンを取り、当直記録の頭を書き起こしていた。インクのペンを使う。この指令所の記録は手書きと決まっている。

判断を下すのはサカキ指令長で、私はそれを書き留める側だ。指令長がなぜそう判断したのかまで、様式に沿って記録に残す。

サカキ指令長は、表示盤を見上げたまま動かずにいた。

入口で足音がした。

「入ります」

カジさんが入ってくる。ファイルを一冊、両腕で抱えていた。

「カジさん。二時のご予約でしたね」指令長は表示盤から目を離さないまま言った。

「夜分にすみません」

カジさんは分厚いファイルを記録机に下ろした。表紙には手書きで「契約書要約台帳」とある。

「これ、今月分です」

ページを開くと、要約の欄が空白のまま、処理済みとして記録されている行がいくつもあった。一行、また一行と、カジさんが指でなぞっていく。

「先週の棚卸しで気づいたんです。数えたら、二十件近くありました」

エラーの記録は一件もない。バッチ処理の実行ログにも、赤い印は一つも付いていない。

「多分、私の契約書の渡し方に問題があったんだと思います」カジさんは言った。「書式が統一されていなかったとか、そういうことかもしれません」

急いでいた日は、スキャンした画像から文字起こししたものを、そのままバッチに渡していたという。「そのせいかもしれません。渡し方が雑だったから」

私は言葉を継ぐ必要もなく、そのまま記録に書き写した。前の回のミナセさんは、誰かに問われて答えられなかったことが始まりだった。カジさんは、誰にも問われていない。自分で見つけて、自分を疑っている。

「その台帳、お預かりします」

指令長はそれだけ言った。断定も、慰めもしなかった。

カジさんは、原因を自分の中に探していた。指令長はまだ、何も答えていない。

第1幕: 持ち込み ── 空欄が二十件

カジさんが椅子に座り直して、続けた。

「契約審査の仕事をしています。取引先から届く契約書を、うちの台帳に載せる前に要約するんです。業務委託とか、秘密保持条項が入ったものが多くて」

三ヶ月前、自分でその要約の仕組みを組んだという。外部のLLM APIに契約書の本文を渡すと、要約が返ってくる。それを台帳に書き写す手間が省けて、運用は順調に見えていた。

「動いてはいたんです。ずっと」

指令長が初めて表示盤から目を離して、椅子を回した。

「その二十件は、いつ気づいたものですか」

「先週の棚卸しです。月末に台帳を見返すことになっていて。空欄のまま処理済みになっている行が、こんなに」

カジさんの指が、ページの端で止まった。

「他の月は、こんなことなかったんです。だから、私が何かおかしなことをしたんだと思って」

指令長は、その台帳をしばらく黙って見ていた。

「今夜のところは、まだ何も言えません」と指令長は言った。「見せていただいたのは、空欄の行だけです」

カジさんが頷く。表情は変わらなかった。

第2幕: 照合 ── 守っていたのに、守れていなかった

カジさんがノートパソコンの蓋を開ける。画面の白い光が、インク壺の縁にかかった。

「バッチ処理がここで例外を出して止まると、他の契約書の処理まで止まってしまうので、必ず何かしらの値が返るようにしました」

声には張りがあった。指令長が画面を覗き込む。私も横から見た。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from dataclasses import dataclass


@dataclass
class Summary:
    body: str
    finish_reason: str


class SummaryDesk:
    def __init__(self, client):
        self._client = client

    async def summarize(self, contract_text: str) -> str:
        reply = await self._client.complete(contract_text)
        summary = Summary(
            body=reply.get("body", ""),
            finish_reason=reply.get("finish_reason", ""),
        )
        return summary.body

dataclass」と指令長が一言だけ添えた。「値をまとめて持つ器、でしたね」

Summary という器には、本文だけでなく finish_reason という値も入るようになっている。私はそれを見て、少し引っかかった。前の話では cited という値が受け取られたまま使われていなかった。今回もまた、似たことが起きているのだろうか。

.get() で、値が無くても空文字列になるようにしてあります」カジさんが続けた。「これで、応答の形が多少崩れていても、バッチは止まりません」

声にはまだ張りがあった。それは正しい判断のはずだった。

経路をたどる

「一つずつ、たどりましょう」と指令長は言った。

「応答が届く。ここで例外は起きますか」

「起きません。届くので」

Summary を作る。ここは」

カジさんが画面を目で追った。「……起きないです。.get() で守ってあるので」

「本文を返す。ここは」

「起きません」

指令長は画面から手を離さずに言った。

「起きる場所を、一つずつ聞きました。どこにもありませんでした」

カジさんの声が、少し小さくなった。

「……守れてなかった、ということですよね」

「守ってはいます」指令長は言った。「ただし、何を守ったかが問題です」

画面の .get(..., "") を指す。

「これは、値が欠けていても処理が止まらないようにする防御です。よく効いています。効きすぎて、欠けていたという事実まで、消しています」

私は手を止めた。カジさんは処理を止めないための手を打った。その手が、記録を残さないための手にもなっていた。

「バッチ全体を止めないため、というのは、当時の判断として正しかったはずです」指令長は続けた。「三ヶ月前、この仕組みを組んだとき、まず動き続けることが優先だったのでは」

「……はい。処理件数が多くて、一件で全部止まるのは避けたくて」

「それは成果です」

カジさんが、少しだけ息をついた。

守りすぎた二十件の外側

「台帳を、もう一度見せてください」

カジさんがファイルを開き直す。指令長が、空欄以外の行にも目を止めた。

「これ——文章が途中で切れていませんか」

カジさんが覗き込む。「第3条まで、で終わってる……気づいてませんでした」

指令長がもう一つ指す。「こちらは、内容が定型文のようです」

カジさんが読んで、少し驚いた顔をした。「ああ、これは『対応できません』みたいな一文だけの日ですね。おかしいとは思ってましたけど、AIがそういう日もあるのかと」

私は詰まった。空欄だけが失敗ではなかった。カジさんが数えた二十件の外にも、同じところから出ている失敗があった。

「私が数えたのは、空欄の行だけです」カジさんが言った。「これは、数に入れていませんでした」

「入りません、今のままでは」指令長は言った。「空欄も、途中で切れているものも、定型文だけのものも、この台帳では同じ処理済みとして並んでいます」

「私の渡し方が悪かったわけじゃ、ないんですか」

指令長は少し間を置いた。

「向こう側の会社の公式資料に、書いてあります」

外部のLLM APIは、応答が正常に完結できなかった理由を、応答そのものに含めて返すことがあるという。安全性の判断で応答を拒否した場合でも、それは通信の失敗としてではなく、普通に成功した応答として返ってくる、と公式の文書に明記されている。

「あなたの渡し方の問題ではありません」指令長は言った。「この仕組みは、おかしな応答を記録する方法を、最初から持っていませんでした」

私は、カジさんが指したページの余白に、三つの失敗がどこへ流れ込んでいるのかをたどってみた。

本文が空のまま届く・途中で打ち切られる・安全性判断で拒否される、という3つの異なる原因が、いずれも.get()のデフォルト値によって同じ空文字列に変換され、エラー記録0件のまま台帳へ流れ込む構造図

入り口は三つ違う。出口は一つしかない。台帳の上では、その一つがずっと「処理済み」と呼ばれていた。

カジさんが息をついた。「……二十件だけだと思っていました」

第3幕: 指令の一手 ── 契約違反を、意味のある例外へ翻訳する

「じゃあ、どうすればよかったんでしょうか」カジさんが訊いた。

「守る場所を変えます」指令長は言った。「Exception Translation——低レベルで起きたことを、意味の分かる形の例外に翻訳して、呼び出し元へ伝える設計です」

そこで指令長は一言、区別を挟んだ。

「これから『契約』という言葉を使いますが、契約書の中身の話ではありません。応答について、何を保証し何を保証しないかという取り決めの方です」

カジさんが少し戸惑って、それから自分で言葉にした。

「……ああ、紛らわしいですね。扱ってるものが契約書なので」

「はい。ここから先の『契約』は、応答についての取り決めのことだと思ってください」

原因ごとに分ける例外

指令長がキーボードをカジさんの方へ滑らせた。指令は打たない。書くのはカジさんだ。

 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
from dataclasses import dataclass

from before import Summary


@dataclass(frozen=True)
class SummaryContract:
    truncated_reasons: frozenset[str]
    refused_reasons: frozenset[str]


class SilentFailureError(Exception):
    """契約に違反した応答を表す基底例外。"""


class EmptyResponseError(SilentFailureError):
    """本文が空だった。"""


class TruncatedResponseError(SilentFailureError):
    """打ち切られた。finish_reason が上限到達を示す場合と、
    応答そのものが欠落していた場合の両方でこの例外になる。"""


class RefusedResponseError(SilentFailureError):
    """安全性分類器の判断により拒否された。"""

「基底を一つ作ります」指令長は言った。「ここに来れば、すべての契約違反を一度に捕まえられる。その下に、原因ごとの具体例外を分けます」

「なぜ、一つの例外にまとめないんですか」カジさんが訊いた。「捕まえるだけなら、一つで足りる気がします」

「まとめれば捕まえるのは楽になりますが、何が起きたかが分からなくなります」指令長は言った。「空だったのか、切れたのか、断られたのか。原因によって、直す場所も、対応も違います」

「打ち切りと、拒否と……三つで足りるんですか」

「今夜はこの三つです。あなたの台帳に出ていたのは、この三つでした」

判定を書く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def judge(reply: dict, contract: SummaryContract) -> None:
    try:
        finish_reason = reply["finish_reason"]
        body = reply["body"]
    except KeyError as e:
        raise TruncatedResponseError("応答が途中で欠落しています") from e

    if finish_reason in contract.truncated_reasons:
        raise TruncatedResponseError(finish_reason)
    if finish_reason in contract.refused_reasons:
        raise RefusedResponseError(finish_reason)
    if not body.strip():
        raise EmptyResponseError()

finish_reasonlength なら打ち切り。content_filter なら拒否。どちらでもなくて本文が空なら、空応答です」

カジさんが自分で確かめるように言った。「さっきの『荷が空だった』は、この最後の一つだけの話だったんですね」

「そうです」指令長は言った。「三つとも同じ言い方で片付けると、違いが見えなくなります」

「向こうの会社によって、この値の名前は違うんですか」私は職務として訊いた。

「違います」指令長は言った。「ある社は finish_reason という名前で、打ち切りを length、拒否を content_filter と呼びます。別の社は stop_reason という名前で、打ち切りを max_tokens、拒否を refusal と呼ぶ。呼び方は揃っていません。今夜のコードは、前者の呼び方に合わせています」

「揃っていないのに、扱いは同じでいいんですか」カジさんが訊いた。

「意味は同じです。呼び方が違うだけなら、契約を組み立てる場所で吸収できます」

もう一つの欠落

指令長が judge の一番上、try の節を指した。

「もう一つ、応答そのものが欠けている場合があります。ここは from e で、元の原因を残したまま翻訳します」

「さっきの空応答と何が違うんですか」

「空応答は、finish_reason も本文も届いています。中身が空なだけです」指令長は言った。「こちらは、届いたはずの欄そのものが無い。もっと手前で壊れています」

「さっき、原因ごとに型を分けると言っていましたよね。これは打ち切りと同じ型なんですか」カジさんが訊いた。

「型は同じにしました」指令長は言った。「壊れた場所は違いますが、対応は同じだからです。どちらも、契約書を最初から出し直すしかありません。対応が分かれるところだけ、型を分けています」

カジさんが少し間を置いて、それから訊いた。

「……では、これまで空欄で記録されていた分は」

指令長はすぐには答えなかった。

「過去の分は、わかりません」

私はペンを止めた。

「わからないことが、わかるようになっただけです」

書こうとして、少し迷った。「過去の分は救えない」と書くべきか、それとも別の言葉があるのか。結局、私は「今夜以降の分」とだけ書き足した。それ以上は、記録に残せる形にならなかった。

「テストで確かめます」指令長は言葉を継いだ。「応答のキーが欠けている場合を、わざと作ります」

1
2
3
4
5
6
7
async def test_応答のキーが欠落している場合もTruncatedResponseErrorになり原因が保持される(
    self,
):
    reply = {"body": "第3条まで..."}
    with self.assertRaises(TruncatedResponseError) as cm:
        judge(reply, make_contract())
    self.assertIsInstance(cm.exception.__cause__, KeyError)

__cause__ というのは」カジさんが訊いた。

「翻訳する前の、元の原因です」指令長は言った。「raise ... from e と書くと、新しい例外の __cause__ に、元の例外が入ります。捨てていません。表には出さずに、裏に残しています」

「表に出さない方がいい場合もあるんですか」

raise ... from None という書き方もあります」指令長は言った。「元の原因を隠す書き方です。今夜は使いません。原因を追えなくすることは、この話の目的と逆です」

私は、応答が judge を通り抜けるまでの分かれ道を、紙の上に描き直した。

応答replyがfinish_reasonとbodyのキー揃いを判定し、欠落ならTruncatedResponseError(raise from e)、揃っていればfinish_reasonの値でTruncatedResponseError/RefusedResponseError/さらにbodyの空判定でEmptyResponseErrorまたは契約適合に分岐し、4つの例外はすべて基底のSilentFailureErrorに属するという分岐図

出口は四つに分かれている。それでも、どの出口から出ても、たどり着く先は同じ扉──SilentFailureErrorだった。潜り方が違うだけだ。

配線の場所

カジさんが SummaryDesk の続きを書く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class SummaryDesk:
    def __init__(self, client, contract: SummaryContract):
        self._client = client
        self._contract = contract

    async def summarize(self, contract_text: str) -> str:
        reply = await self._client.complete(contract_text)
        judge(reply, self._contract)
        summary = Summary(body=reply["body"], finish_reason=reply["finish_reason"])
        return summary.body

Summary は、さっきのものと同じ器を使います」指令長は言った。「判定を通ったあとで組み立てる、という順番だけが違います」

カジさんが手を止めた。「……この truncated_reasons とか refused_reasons って、どこで決めて渡すものなんですか」

指令長がスニペットを見せる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
TRUNCATED_REASONS = frozenset({"length"})
REFUSED_REASONS = frozenset({"content_filter"})


def build_desk(client) -> SummaryDesk:
    contract = SummaryContract(
        truncated_reasons=TRUNCATED_REASONS,
        refused_reasons=REFUSED_REASONS,
    )
    return SummaryDesk(client=client, contract=contract)

summarize の呼び方そのものは変わりません」指令長は言った。「ただし、どの finish_reason を違反とみなすかを決めて、契約を組み立てる場所は要ります。今回は、ここに置きました」

「呼び出し側は、何も知らなくていいんですか」

「知らなくていいのと、無いのは、違います」指令長は言った。「窓口の向こうに隠れただけで、無くなってはいません」

カジさんが少し考えてから言った。「……ということは、バッチの方にも手を入れないといけないんですね」

「そうです」指令長は言った。「今夜作ったのは、判定と翻訳までです」

第4幕: シミュレータと引き渡し ── 過去は分からない。これからは分かる

同じシミュレータを流し直した。今度は七本ある。

読み上げるのは私の役目だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
test_Beforeは契約違反の応答も例外を出さず素通しする ... ok
test_契約に適合する応答ならBeforeとAfterは同じ要約を返す ... ok
test_契約違反の理由は例外の型で判別できる ... ok
test_応答のキーが欠落している場合もTruncatedResponseErrorになり原因が保持される ... ok
test_打ち切られた応答はTruncatedResponseErrorになる ... ok
test_拒否された応答はRefusedResponseErrorになる ... ok
test_本文が空の応答はEmptyResponseErrorになる ... ok

----------------------------------------------------------------------
Ran 7 tests in 0.008s

OK

指令長が三本を指して、順に噛み砕いた。

一本目は、契約に適合するあいだ、組み直す前と後で同じ要約が返ること。

「組み直す前と、要約の中身が同じなんですね」カジさんが言った。

「同じでなければ困ります」指令長は言った。「動く箇所があるとすれば、契約に合わなかったときだけです」

もう一本は、組み直す前の窓口が、三種類の違反応答をすべてそのまま返してしまうこと。空応答、打ち切られた応答、拒否の定型文——どれも例外を出さず、素通りする。

「私が見ていた二十件は、この二本目の一部だったんですね」カジさんが言った。

「一部です。空欄になっていない分も、素通りしていました」

「三種類とも、通ってしまうんですね」

「通ります。そして、通ることが問題です。三つとも」

最後の一本は、応答のキーが欠けているときも、元の原因が __cause__ に保持されていること。

「これは、通ってしまう方の話じゃないんですね」カジさんが訊いた。

「はい」指令長は言った。「こちらは組み直したあとの話です。原因を捨てずに翻訳できているかを確かめています」

七本とも通った。実行に要したのは、〇・〇〇八秒だった。

保証すること、保証しないこと

指令長が区切って言葉にした。

保証するのは、応答が契約に違反しているとき、それを独自例外として検出し、原因ごとに分けて送出できること。

「保証しないことが、もう一つあります」指令長は言った。「この例外を受け取った後、どう扱うかは決めていません」

カジさんが顔を上げた。

「もう一度送り直すのか、その契約書だけ後回しにするのか、処理を止めるのか——それは、こちら側では決められません。運用している側が決めることです」

「それは、今夜のところは私が決める、ということですか」

「そうです」指令長は言った。「私たちが渡せるのは、失敗を失敗だと分かる形にするところまでです」

カジさんは少し黙って、それから頷いた。

記録

カジさんがファイルをまとめる横で、私はペンを走らせていた。

カジさんがふと私の手元を覗き込んで言った。

「あの……見慣れない欄がありますね」

私は手を止めて、様式を見せた。

「これですか」

それだけで、話はそこで終わった。カジさんは少し首をかしげただけで、それ以上は訊かなかった。

引き渡し

カジさんがファイルを閉じた。

「今月分の空欄は、直せないんですよね」

「直せません」指令長は言った。「ただ、来月からは同じ形では起きません。起きたときに、起きたと分かる形になります」

カジさんが少し黙ってから言った。

「……自分の渡し方のせいじゃなかった、というのは分かりました。あとは……バッチ側の話ですね」

「そうです」指令長は言った。「そこから先は、また別の話です」

カジさんが小さく頭を下げて、「今夜は助かりました」とだけ言った。

足音が遠ざかっていく。表示盤の点はさっきから変わらず、同じ調子で盤面を渡っている。

今夜、過去の二十件が埋まることはなかった。それでも、カジさんは自分を疑うのをやめて帰った。台帳の空欄より、そちらの方が先に埋まった。私は「結果」の欄に、その一行を書いた。


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

  • 指令の定石(パターン名): Exception Translation(例外の翻訳)── 低レベルで起きたことを、意味の分かる形の独自例外に翻訳して、呼び出し元へ伝える設計
  • 申告された症状: 「要約が空のまま台帳に載っている(約二十件)。自分の入力の渡し方が悪いのではと疑っていた」。実際には、契約違反を記録する仕組みがそもそも存在せず、渡し方とは無関係だった
  • 今のダイヤの問題: 応答が欠けていても処理が止まらないようにする防御(.get() のデフォルト値)が、欠けていたという事実そのものを空文字列へ変換し、正常な応答と区別できなくしていた。バッチ全体を止めないための判断としては、当時の条件で合理的だった
  • 打った一手: 契約違反を検出し、原因(空/打ち切り/拒否)ごとに分けた独自例外として送出し直す設計に変えた。応答の形が壊れている場合は raise ... from e で元の原因を保持したまま翻訳する
  • 配線した場所: 起動時の組み立て(build_desk)。どの finish_reason を違反とみなすかを決めて契約を組み立て、窓口へ渡している。呼び出し側のコードは変えていない
  • 保証しないこと: 検出した契約違反を、呼び出し元がどう扱うか(再送するか、後回しにするか、処理を止めるか)は運用側の判断に委ねる。過去にすでに空欄のまま記録された分を遡って直すこともできない
  • シミュレータ結果: 七本、〇・〇〇八秒。契約に適合するあいだ組み直す前と後で戻り値が変わらないこと、三種の契約違反がそれぞれ対応する例外になること、応答のキー欠落時も元の原因が __cause__ に保持されること、そして組み直す前の窓口が三種の違反応答をすべて素通しすることを確かめた
  • 次の当直への申し送り: 今夜以降の契約違反は例外として記録に残る。今月すでに記録された空欄は、この一手では埋まらない

成功として記録されているのに、中身が空——そんな記録に、心当たりはありませんか。

エラーが一件も出ていないことは、何も壊れていないことの証明にはなりません。契約違反を検出する仕組みが無いだけかもしれません。心当たりがあれば、Meetsource の相談窓口から声をかけてください。

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