Featured image of post コードディスパッチャー【Output Contract】「これは障害ですか」に答えられなかった〜揺れてよい範囲を先に決めて、異常だと言えるようにする〜

コードディスパッチャー【Output Contract】「これは障害ですか」に答えられなかった〜揺れてよい範囲を先に決めて、異常だと言えるようにする〜

同じ問い合わせに毎回違う文章が返るLLM応答。揺れを止める代わりに、揺れてよい範囲と許さない範囲を出力の契約として先に決め、判定が揺れに影響されないことをPythonの標準ライブラリだけで確かめます。

深夜一時を回ったところだった。

当直交代の足音が廊下の奥へ消えて、指令所には空調の低い唸りだけが残っている。前の当直が置いていった湯呑みが、記録机の隅でまだうっすら温かい。壁一面の運行表示盤には緑の点がいくつも灯っていて、そのどれもが、私の目には同じ速さで動いているように見える。

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

私は指令を打たない。サカキ指令長が下した判断と、その理由を、決められた様式で記録に残す。それが私の仕事だ。指令長は判断の理由を必ず言葉にする人で、私はそれを聞き漏らさないために、たいてい黙っている。

サカキ指令長は表示盤の前に立っていた。

第1幕: 持ち込み ── 症状ではなく、答えられなかったこと

入口のほうで足音がした。

「入ります」

小さな声だった。ミナセさんが薄いノートパソコンを一台だけ抱えて入ってくる。指令長は表示盤から目を離さないまま言った。

「ミナセさん。一時でしたね」

「はい。……夜分にすみません、緊急でもないのに」

ミナセさんが頭を下げる。私はその角度を見て、記録の一行目に「来訪(予約)」と書いた。緊急ではない相談が予約で入るのは、この指令所ではそう珍しいことではない。ただ、来る人はたいてい、こうして申し訳なさそうにする。

「緊急でないほうがいい」と指令長は言った。「緊急になってから来られると、こちらは手が足りません」

ミナセさんの肩が、少しだけ下りた。

椅子を引いて座り、ノートパソコンを膝に置いたまま、ミナセさんは話しはじめた。勤め先は中堅の製造業で、情報システムの担当はミナセさんひとりだという。社内規程についての問い合わせが総務に集中していて、それを捌くために、規程を読ませて答えを返す仕組みを半年前に自分で組んだ。総務の問い合わせ窓口の前に置いてある。

「動いてはいるんです」

ミナセさんはそう言った。実際、総務への問い合わせは目に見えて減ったらしい。

「ただ、同じことを訊いても、返ってくる答えが日によって違うんです」

先週、自分で試したのだという。「有給休暇の繰り越しは何日までですか」と二度訊いた。二度とも、書いてある内容は合っていた。合っていたが、文章が違った。

「一回目は就業規則の話から入って、二回目はいきなり結論から書いてあって。どっちも間違ってはいないんですけど」

私は「申告された症状」の欄に、ミナセさんの言葉をそのまま書き写した。技術の言葉に翻訳するのは私の仕事だが、翻訳する前の言葉を残すのも私の仕事だ。あとで読み返したとき、どちらか一方しか残っていない記録は役に立たない。

ミナセさんは少し黙って、それから続けた。

「先週、上長に訊かれました。『これは障害ですか』って」

ミナセさんの視線が、膝の上のノートパソコンに落ちた。

「答えられませんでした。障害です、とも、違います、とも言えなくて。……調べます、としか言えませんでした」

恥じている口調ではなかった。困っている口調だった。

「それで調べたんです。でも、何を調べれば答えが出るのかが、分からなくて」

指令長が初めて表示盤から目を離した。椅子を回して、ミナセさんのほうを向く。

「揺れているかどうかは、症状じゃありません」

「……え」

「同じ指示で同じ区間を走らせても、所要時間は毎日わずかに違います。それは異常ではありません」

ミナセさんが、少し身を乗り出した。

「でも、違う答えが返ってきてるんです。それを異常と呼ばないなら、何を異常と呼ぶんですか」

「それを、これから決めます」

指令長はそこで一度、言葉を区切った。

「所要時間の話は、ここまでにします。たとえ話を続けると、仕様が曖昧になる。実際に何が起きているかで話しましょう」

私はペンを止めずに書いた。症状は言葉になった。ただ、ミナセさんが持ち込んだ言葉と、指令長が返した言葉は、まだ噛み合っていない。それでも、書ける欄が一つ埋まった。

第2幕: 照合 ── 形はもう整っている

ミナセさんがノートパソコンを開いて、記録机の空いた場所に置いた。画面の白い光が、机の上のインク壺の縁を照らす。

「形はちゃんとしてるはずなんです」

その言い方には、はっきりと張りがあった。入ってきてからずっと詫びるような調子だったのが、ここだけ変わった。

「最初は文章だけ返してたんですけど、それだと根拠が分からないので、番号も一緒に返すようにしました。どの規程を見て答えたのか、っていう」

指令長が画面を覗き込む。私も横から見た。

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


@dataclass
class Answer:
    body: str
    cited: list[str]


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

    async def ask(self, question: str) -> str:
        reply = await self._client.complete(question)
        answer = Answer(body=reply["body"], cited=reply["cited"])
        if not answer.body:
            return "回答を作成できませんでした。"
        return answer.body

「読みながら説明します」と指令長は言った。「まず asyncawait。外に問い合わせて、返ってくるまで待つ、という書き方です。待っているあいだ、他の仕事に手を回せる。この路線は外の相手に問い合わせているので、この形になります」

外の相手、というのは他社線への直通のことだ。ダイヤも車両もこちらの管轄ではない。遅れても、こちらから直す手段がない。

「次に dataclass。値をまとめて持つだけの器を、短く定義する書き方です。ここでは Answer が器で、中身は本文と、引用した規程番号の一覧」

ミナセさんが頷く。

「それから、cited: list[str] と書いてあります」と指令長は続けた。「文字列の一覧です、という宣言です。ただし Python は、これを実行時に検査するわけではありません。mypy のような検査を別に走らせないかぎり、これは書いてあるだけです」

私はそれを書き取った。型ヒント——値の種類を書き添える記法。書いてあっても、それだけでは検査されない。

書いてあることと、保証されていること。私はその二つを、これまで同じものだと思っていた。

指令長が画面の一箇所を指した。

cited を、受け取っていますね」

「はい」

「どこで使っていますか」

ミナセさんが画面をスクロールした。上へ、下へ。それからもう一度、上へ。

「……使ってない」

指令長は指を離さないまま言った。

「形はもう整っています。それでも、正常かどうかは言えません」

私はペンを持ち直した。

「整っているのは受け取り方であって、受け取ったものが正しいかどうかを、このダイヤは一度も見ていない」

answer.cited という式が、このコードのどこにも出てこない。受け取って、器に詰めて、そのまま置いてある。

指令長は声のトーンを少し落として続けた。

「半年前、これを組んだとき、まず届くことが仕事だったはずです」

ミナセさんが頷いた。

「……はい。とにかく、答えが返ってくるようにするので精一杯で」

「総務の前に置いて、問い合わせが減った。それは成果です。当時の本数では、この組み方で合っていた」

ミナセさんが、少しだけ顔を上げた。

指令長は、持ち込まれたダイヤを笑わない。まず、なぜそう組んだのかを読む。それから組み直す。私は毎回、その順番を記録に書いている。

揺れを止めようとした半年

「揺れを止めようとはしたんです」とミナセさんが自分から言った。

temperature という設定を下げた。答えのばらつきを抑える方向の調整だという。それでも揺れた。次に seed を固定した。同じ種を渡せば同じ結果が返る、という考え方の設定だ。それでも揺れた。

「なんで効かないのか、分からなくて」

「公式の説明に、最初から書いてあります」と指令長は言った。

OpenAI の文書には、seed について “make a best effort to sample deterministically” ——決定論的にサンプリングするようベストエフォートで努める、とある。そのすぐあとに、“Determinism is not guaranteed” と続く。決定性は保証されない。

Google の文書もほとんど同じ言い回しだという。“the model makes a best effort to provide the same response for repeated requests. Deterministic output isn’t guaranteed.”

「Anthropic には、seed というパラメータがそもそもありません」と指令長は言った。「文書に残っているのは一行です。temperature0.0 であっても、結果は完全には決定論的にならない、と」

ミナセさんが、口を半分開けたまま止まった。

「……最初から、保証しないって書いてあったんですね」

「書いてあります。それと、OpenAI の文書には、揺れを監視するなら system_fingerprint という値を見ろ、とも書いてあります。その値は、いまのリファレンスでは非推奨になっています」

監視する手段のほうが先に退いている。私はそれも書いた。

なぜ揺れるのか、という話は出なかった。指令長はそこへは入らなかった。保証されていない、という事実だけで、今夜の話には足りるからだ。

ミナセさんが、そこで私の手元に目をやった。

「あの、ずっと書かれてますけど」

「記録係のトウヤです」と指令長が表示盤のほうを向いたまま言った。「ここで決めたことは全部そこに残ります」

「トウヤです」と私は言った。「あとで読み返せるように書いています」

ミナセさんが軽く会釈した。以降、私はトウヤさんと呼ばれた。

二つの問い

指令長が椅子を回して、ミナセさんに向き直った。

「一つ訊きます。今夜、あの路線に異常が起きたとして。あなたは何を見て、それが異常だと分かりますか」

ミナセさんが口を開いて、閉じた。

「……分かりません。何を見ればいいのかが、分からない。だから先週も、答えられなかったんです」

「では、正常のほうは。何を見れば、正常だと言えますか」

今度はもっと長い沈黙だった。空調の音が、いやに大きく聞こえた。

「……何も、無いです」

「そこです」と指令長は言った。「異常を検知する仕組みが無いんじゃない。正常が定義されていない。定義が無いところに、検知も、テストも、直したかどうかの判定も、乗りません」

ミナセさんが、さっき自分で口にした言葉を拾い直すように言った。

「……何を異常と呼ぶのか、を決めていないから」

「決めていないものは、出ても分かりません」

私は「今のダイヤの問題」の欄に、聞いたばかりの言葉を書いた。壊れているのはコードではなかった。ミナセさんが最初に持ち込んだ困りごとと、いま指令長が名指したものが、ここでようやく一本になった。

鳴らない警報

「シミュレータを一度流します」と指令長は言った。

運転シミュレータというのは、営業運転に出さずに乱れを起こして手順を確かめる装置のことだ。コードで言えばテストにあたる。

「ただ、流す前に一つ決めます」と指令長は続けた。「異常が定義されていないなら、異常を作れません。作れないものは、試せない」

そう言って、ミナセさんに訊いた。

「社内規程台帳に載っていない番号を引いた回答。これは、異常でいいですか」

ミナセさんは迷わなかった。

「異常です。うちに無い規程を出したら、まずいです」

「では、それを一つだけ、今夜の異常ということにします。ほかは決めていません」

指令長はミナセさんに、外の相手の代わりをする偽の応答を一つ用意させた。台帳に無い規程番号——S-412 を引いた回答だ。社内規程台帳には S-114S-207S-330 しか載っていない。

走らせた。

……何も起きなかった。回答が、普通に返ってきた。

「通りました」

ミナセさんはそこで声を止めて、それから、いま自分が言ったことを確かめるように続けた。

「いま二人で異常だって決めたものが、そのまま外へ出ました」

使っていないんだから通るのは当たり前だ、と私は思った。さっき自分で確かめたばかりだ。cited はどこでも使われていない。

「当然です」と指令長は言った。「使っていないんですから、通ります。見たかったのは、通ったあとです」

そしてミナセさんに訊いた。

「この回答は、どこで止まりましたか」

ミナセさんが画面を追った。追って、追って、それから顔を上げた。

「……止まってないです。窓口を出て、利用者に返ってます」

「もう一度、正確に言います」と指令長は言った。「二人で異常だと決めたものを流しても、この窓口の振る舞いは、正常なときと一つも違いませんでした。例外も出ていない。印も残っていない。返し方も同じです。区別が付いていない、ということです」

私はそれを書いた。異常だと決めたものが、正常なものと同じ顔で外へ出ていった。区別が付かないというのは、そういうことだ。

「本番で同じことが起きても、いまと同じです」と指令長は続けた。「誰も気づきません。気づく手段が、どこにも無いので」

シミュレータは、組んだ手順が乱れを吸収できないときに警報を鳴らす装置だ。今夜は鳴らなかった。警報は、鳴らすものを先に決めておかないと鳴らない。

ミナセさんが、少し戸惑った顔で訊いた。

「……これ、私が相談に来た話と、繋がってるんですか。文章が毎回変わる、っていう」

「同じ話です」

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

「揺れを見ていたから、揺れじゃないものが見えなかった」

そして噛み砕いた。

「文章が毎回変わる。あなたはそれを、半年見ていました。番号のほうは」

ミナセさんが、ゆっくり言った。

「……見て、ないです。そもそも見てもいなかった」

「見ていないものは、変わっても分かりません。今夜わざと変えてみるまで、誰も」

見ているものと、見ていないものがある。見ていないほうで何が起きても、記録には残らない。ミナセさんが持ち込んだ症状と、いま外へ出ていった存在しない規程番号は、同じ一つの欠落から出ている。私は記録の余白にそう書いた。

「……これ、半年やってました」

ミナセさんがそう言ったとき、責める言葉は誰からも出なかった。指令長は表示盤のほうを向いて、何も言わなかった。

第3幕: 指令の一手 ── 揺れてよい範囲を先に決める

ミナセさんが顔を上げて訊いた。

「じゃあ、どうすれば揺れなくなるんですか」

訊いてから、ミナセさんは口を結んだ。同じことをもう一度口にした、という顔だった。

「揺れなくはなりません」

即答だった。

「他社線のダイヤは、こちらでは直せません。直せないものを前提に、こちら側の手順を決めます」

指令長は続けた。

「止まらない方法じゃない。止まり方を決めるんです」

そして、その言葉をこの路線につないだ。

「あなたの路線は、止まりません。落ちもしないし、エラーも返ってこない。動いたまま、間違ったものを出します。止まるという形が無いんです」

指令長はそこで一度、言葉を切った。

「止まる形が無い路線では、どうなったら止まったことにするのかを、こちらで決めるしかありません。さっき二人で一つ決めましたね。台帳に無い番号を引いた回答は異常だ、と。あれを増やして、線を引きます」

私はその言い方を記録に書き写した。指令長の言葉は、たいてい判断とセットで出てくる。今のは判断の前に出た。だから、たぶんこれは判断ではなく、指令という仕事そのものの説明だ。

契約を二つに割る

指令長が立って、記録机の紙を一枚もらった。私のペンを借りて、真ん中に線を一本引く。

「出力の契約——返ってくるものについて、何を保証し、何を保証しないかを、受け取る側が先に決めておく取り決めです」

線の左に「揺れてよい」、右に「揺れてはいけない」と書いた。私は出力の契約という語を、定義ごと書き取った。

左に入るのは回答本文だ。言い回し、長さ、語順。同じ問い合わせに同じ文章が返ることは、保証しない。

右に入るのは、引用された規程番号。

「つまり、文章はどう変わってもいい。でも番号は……」とミナセさんが自分の言葉に置き換える。

「番号は、台帳に載っているものでなければいけない」

「この左側のことを、同値範囲と呼びます」と指令長は言った。「ここまでの違いは同じものとみなす、と決めた範囲です。定時運行率と同じ考え方で、全部を定時にするのではなく、どこまでの遅れを許すかを先に決める」

同値範囲。私はその語も書き取った。定時運行率という言葉が出たので、そちらも欄外に添えた。

契約が保証しないこと

私は職務として訊いた。

「保証するのは、番号が台帳にあることだけですか」

「そうです」

「その番号が、この問い合わせに対して正しい規程かどうかは」

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

「契約の外です」

私は書こうとして、手が止まった。

合格した回答が、間違った規程を引いている——それは、この契約では捕まらない。捕まらないことを、いま決めた。私は「保証しないこと」の欄に、その一行を書いた。

判定を二段に分ける

指令長はキーボードを打たない。指令が自分で運転しないのと同じで、書くのはミナセさんだ。組み直す前のコードは別のファイルに残してあって、器の Answer はそちらから持ってくる。同じ器を使うこと自体が、今夜の一手の条件になっている。

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

from before import Answer


@dataclass(frozen=True)
class AnswerContract:
    known_codes: frozenset[str]


@dataclass(frozen=True)
class Verdict:
    ok: bool
    reasons: tuple[str, ...]


class ContractViolation(Exception):
    def __init__(self, verdict: Verdict):
        super().__init__("、".join(verdict.reasons))
        self.verdict = verdict


def judge(answer: Answer, contract: AnswerContract) -> Verdict:
    reasons: list[str] = []

    # 一段目 ── 揃っているか(応答だけを見れば決まる)
    if not answer.body.strip():
        reasons.append("回答本文が空")
    if not answer.cited:
        reasons.append("引用された規程番号が無い")

    # 二段目 ── 実在するか(台帳と突き合わせて初めて決まる)
    for code in answer.cited:
        if code not in contract.known_codes:
            reasons.append(f"台帳に無い規程番号: {code}")

    return Verdict(ok=not reasons, reasons=tuple(reasons))

frozen=True は、あとから書き換えられない器にする指定です」と指令長は言った。「契約が途中で変わったら、判定の意味が無くなるので」

指令長はそこにもう一つ足した。

「書き換えられなくすると、もう一つ性質が付いてきます。中身が同じ器どうしを、同じものとして扱えるようになる。あとで効きます」

Verdict のほうの reasons が、一覧ではなく tuple になっているのも同じ理由だという。書き換えられない並びにしておくと、器ごと同じかどうかを比べられる。tuple[str, ...]... は、同じ種類がいくつでも並ぶ、という書き方だそうだ。

frozenset も中身を変えられない集合で、番号が入っているかどうかを調べるのに向いている。一覧の先頭から順に見ていく必要がないので、台帳が何千件になっても引く速さが変わらない。

「検査が二段になっているのが分かりますか」

ミナセさんが画面を見る。

「一段目は、揃っているか。本文が空でないか、番号が付いているか。これは応答だけを見れば決まります。二段目は、実在するか。これは応答だけを見ても決まらない。応答の外にある台帳と突き合わせて、初めて決まります」

「……分けるのは、なぜですか」

「直し方が違うからです」と指令長は言った。「一段目で不合格になったら、返し方の問題。二段目で不合格になったら、中身の問題。不合格の理由を混ぜると、どちらを直せばいいか分からなくなる」

ミナセさんが画面を指した。

「でも、理由は同じ一覧に入ってますよね。混ぜるなって言ったのに」

「入っています」と指令長はあっさり認めた。「今夜は、理由の文言で見分けが付きます。段ごとに扱いを変える必要が出てきたら、そのときに一覧を割ります。要るようになる前に割ると、割った形のほうを守る仕事が増える」

私はその判断も書いた。分けたのは考え方であって、まだ器ではない。

そのうえで窓口に判定を挟む。

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

    async def ask(self, question: str) -> str:
        reply = await self._client.complete(question)
        answer = Answer(body=reply["body"], cited=reply["cited"])
        verdict = judge(answer, self._contract)
        if not verdict.ok:
            raise ContractViolation(verdict)
        return answer.body

ask の中で変わったのは一箇所だけだ。応答を受け取ってから返すまでのあいだに、判定が一つ挟まった。外への投げ方も、受け取る器も、そのままにしてある。

私は職務として訊いた。

「合格しなかったとき、利用者には何が届きますか」

「何も届きません」と指令長は言った。「組み直す前は、本文が空でも『回答を作成できませんでした』という文字列が返っていました。そこが無くなります」

ミナセさんが少し身を引いた。

「……番号が一つ台帳に無いだけで、答えを一切返さないんですか」

「返しません。ただし、返らなかったことは呼び出し側に伝わります。ここで決めたのは、そこまでです」と指令長は言った。「利用者の画面に何を出すかは、窓口を持っている側が決めることです。黙って捨てるのか、担当者に回すのか、前の回答を出しておくのか。それは今夜の話ではありません」

止まり方を決めるというのは、こういうことだ。止まらなくするのではなく、止まったときに誰が何を知るかを決める。私は「保証しないこと」の欄の下に、決めなかったことを一行足した。

私は組み直す前と後を、同じ紙の上に並べて描いた。

組み直す前の窓口は引用規程番号を参照しないため契約違反の応答が利用者まで届いてしまい、組み直した後の窓口は判定を挟んで合格なら回答本文が利用者へ届き不合格なら窓口で止まって呼び出し側に伝わる、経路の対比図

違うのは、応答がたどる道の長さだけだ。組み直す前は利用者まで伸びていて、組み直した後は窓口で終わっている。

誰が契約を組み立てるのか

ミナセさんが書き終えて、手を止めた。

「……この契約、誰が作るんですか」

「いい質問です」

指令長が言い、ミナセさんが手を動かした。

1
2
3
4
5
6
7
# 社内規程台帳(実在する規程番号の一覧)
KNOWN_CODES = frozenset({"S-114", "S-207", "S-330"})


def build_desk(client) -> InquiryDesk:
    contract = AnswerContract(known_codes=KNOWN_CODES)
    return InquiryDesk(client=client, contract=contract)

「これまでは、窓口を作るところで InquiryDesk(client) と書いていたはずです。そこが build_desk(client) に変わります。変わるのはその一箇所だけで、ask を呼ぶ側のコードは、一行も変わりません。業務のロジックは触らなくていい。ただし、配線は消えていません。誰かがどこかで台帳を読んで、契約を組み立てて、窓口に渡している。それがこの関数です。窓口の向こうに隠れただけで、無くなってはいない」

「今夜は台帳の中身を三件、直に書きました」と指令長は付け足した。「どこから読むかは、この関数の中の話です。ファイルから読もうが、別の系統に問い合わせようが、窓口には影響しません」

隠れたものは、無くなったものではない。記録に残すのは、たいていこちら側だ。

ミナセさんがもう一つ訊いた。

「判定のほうは async にしなくていいんですか」

「待つ相手がいません。台帳はもう手元にあります。待つ相手がいるときだけ、待つ書き方にします」

ミナセさんの画面を最初に覗いたとき、待つ書き方の話を聞いた。それがここで具体的な形を得て閉じた。

第4幕: シミュレータと引き渡し ── 揺れは直らない

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

読み上げるのは私の役目だ。結果を声に出して記録に落とすところまでが、記録係の仕事になっている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
test_Beforeは契約違反の応答も素通しする ... ok
test_不合格の理由には問題のある規程番号が入る ... ok
test_台帳に無い規程番号を引いた応答は不合格になる ... ok
test_契約に適合するあいだBeforeとAfterは同じ回答を返す ... ok
test_契約違反のときAfterは呼び出し側に知らせる ... ok
test_引用が無い応答は不合格になる ... ok
test_本文が空の応答は不合格になる ... ok
test_言い回しの違う三通りの応答でも判定は一致する ... ok

----------------------------------------------------------------------
Ran 8 tests in 0.008s

OK

テストの中身にも async が付いている。外の相手を待つ処理を確かめるので、テスト自身も待てなければならない。指令長によると、unittest.IsolatedAsyncioTestCase を土台にすると、待ちのある処理をそのまま書けるという。実行の段取りを自分で用意しなくてよくなる、という意味だそうだ。使っているのは Python に最初から入っているものだけで、外から持ってきた道具は一つもない。

指令長が三本を指して、順に説明した。

一本目は、言い回しの違う三通りの応答で判定が一致すること。台本にした三通りは、こういうものだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
REPLIES = [
    {
        "body": "就業規則により、未消化の年次有給休暇は翌年度に限り繰り越せます。上限は20日です。",
        "cited": ["S-114"],
    },
    {
        "body": "年次有給休暇の繰り越しについては、翌年度まで、最大20日までとなっています。",
        "cited": ["S-114"],
    },
    {
        "body": "繰り越せます。翌年度末まで有効で、繰り越せる日数の上限は20日です。詳しくは就業規則をご確認ください。",
        "cited": ["S-114"],
    },
]

長さも語順も丁寧さも違う。三通りとも別の文章だ。

1
2
3
4
5
6
7
8
9
    async def test_言い回しの違う三通りの応答でも判定は一致する(self):
        contract = AnswerContract(known_codes=KNOWN_CODES)
        verdicts = [
            judge(Answer(body=reply["body"], cited=reply["cited"]), contract)
            for reply in REPLIES
        ]
        # 文章は三通りとも違う。判定は三通りとも同じ。
        self.assertEqual(len({v for v in verdicts}), 1)
        self.assertTrue(verdicts[0].ok)

「三つとも合格した、を確かめているのではありません」と指令長は言った。「三つの判定を集合に入れて、種類が一つしか無いことを確かめています。全部合格ではなく、全部同じ、を見ている」

書き換えられない器は、中身が同じなら同じものとして扱える。だから種類の数を数えれば、判定が割れていないかが分かる。器を書き換えられなくしたときに聞いた話が、ここで効いた。

私は一つ気になった。三つとも引いている番号は同じ S-114 だ。番号が同じなら判定が同じになるのは、当たり前ではないのか。

そう訊くと、指令長は判定の中身を指した。

「判定は、本文の中身を読んでいません。空かどうかしか見ていない。文章がどう揺れても、この関数がたどる道は一度も変わりません」

揺れてよい部分を、判定が最初から見ていない。だから揺れが判定に届かない。三通りで確かめたのは、その設計がそのとおりに効いているか、ということだ。

私は記録の余白に、応答が判定に届くまでの道を描いた。

外の相手からの応答が回答本文(揺れてよい同値範囲)と引用規程番号(揺れてはいけない)に分かれ、判定は回答本文へは中身を読まない破線でつながり、引用規程番号からは一段目の揃っているかの検査、社内規程台帳からは二段目の実在するかの検査を受けて合格・不合格を出す構造図

回答本文から判定へ伸びる線は、中身を読まない一本しか無い。揺れは、この線を渡れない。

出力が毎回同じであることは、確かめようがない。確かめられるのは、出力が違っても判定が同じであることのほうだ。

二本目は、契約に適合するあいだ、組み直す前と後で回答が変わらないこと。

「変わってないんですね」とミナセさんが言った。

「変わってはいけません。変わるのは、契約に合わないときだけです」

三本目が、さっき区別の付かなかったほうだ。台帳に無い番号を引いた回答を、組み直す前の窓口に食わせる。例外も出ず、本文がそのまま返ってくる。それを確かめるテストが一本ある。素通りしてしまうことを、素通りすると書いて釘を打ってある。組み直した窓口では、同じ応答が不合格になり、理由に S-412 という番号が入る。

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

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

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

保証するのは四つ。本文が空でないこと。番号が付いていること。番号が台帳に実在すること。そして、この判定が言い回しの揺れに影響されないこと。

保証しないのは三つ。本文の内容が業務的に正しいこと。引かれた規程が、その問い合わせに対して適切であること。台帳そのものが最新であること。

「これは、うちだけの話ではありません」と指令長は言った。

Google の文書には、構造化された出力についてこう書いてあるという。“While output is syntactically correct JSON, always validate values in your application.” ——構文としては正しい JSON だが、値はアプリケーション側で常に検証すること。そして、“schema-compliant but semantically incorrect outputs” ——スキーマには準拠しているが、意味的には誤っている出力、という言い回しがそのまま出てくる。

OpenAI の文書は “Structured Outputs doesn’t prevent all kinds of model mistakes” と書いている。あらゆる種類の誤りを防ぐわけではない、と。

「Anthropic には、同じことを書いた文が見当たりません」と指令長は言った。「保証すると書いてあるのは、スキーマに沿っていること、型が合っていること、必須の項目が揃っていること。内容が正しいとは、一度も書いていない」

書いていないことは、保証されていません、と指令長は言った。三社とも、そこは揃っています、と。

書いてあることと、保証されていること。書いてあっても保証されないことがあると、私は今夜知った。そして、書いていないものは、初めから保証されていない。

どこまでの話か

「今この場で確かめられたのは、一つのプロセスの中で、一件の応答が契約に合っているかどうかまでです」と指令長は言った。

台数を増やしたら台帳をどこが持つのか。台帳が古くなったら誰が気づくのか。それは今夜の話ではない、と指令長ははっきり外へ置いた。

私は職務として、もう一つ訊いた。

「では、台帳が古かったら」

「合格します。台帳に載っているんですから」

私はそれも書いた。

記録

ミナセさんが帰り支度をしている横で、私は記録を書き留めていた。

様式には主な欄が三つある。申告、判断、結果。三つとも埋めた。

それから、余白に書き足している自分に気づいた。今夜サカキ指令長が何を保証しないと言ったか。なぜその順で判定を二段に分けたのか。ミナセさんが最初に何と言って入ってきたか。

様式には無い欄まで書いている。いつからそうしているのか、自分でもよく覚えていない。書いておかないと落ち着かない、というだけの話だ。

引き渡し

ミナセさんがノートパソコンを閉じて、少し黙ってから訊いた。

「あの、……揺れは、直ってないですよね」

「直っていません」と指令長は言った。「今夜も揺れます。明日も揺れます」

「……はい」

「直っていないのは揺れです」と指令長は続けた。「今夜変えたのは、別のものです。契約に合わない回答が利用者まで行く道のほうは、塞ぎました。そこは挙動が変わっています」

ミナセさんが、少し考える顔をした。

「揺れは変わらないけど、通り抜ける道は塞がった」

「そうです」

ミナセさんはそこで黙った。それから、自分で言った。

「……答えられます」

「上長に、これは障害ですかって訊かれたら。契約に違反した回答が出ていたら異常です、出ていなければ正常です、って言えます」

一度言って、ミナセさんはもう一度、確かめるように言った。

「言えます。先週は、言えませんでした」

「そこだけです」と指令長は言った。「言えるようになった、というだけで、揺れが減ったわけではありません」

「それでいいんです」

ミナセさんの声が、来たときより少し低かった。

「訊かれて答えられないのが、いちばん困っていたので」

私はそれを書き取った。

ミナセさんは少しのあいだ立っていて、それから頭を下げた。

「サカキさん、トウヤさん。ありがとうございました」

足音が遠ざかっていく。表示盤の緑の点は、さっきと同じ速さで動いている。

揺れは今夜も直らない。それでも塞がった道が一本あって、言えるようになったことが一つある。私は「結果」の欄に、その二つを分けて書いた。


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

  • 指令の定石(パターン名): 出力の契約(Output Contract)── 返ってくるものについて、何を保証し何を保証しないかを、受け取る側が先に決めておく取り決め
  • 申告された症状: 「同じことを訊いても、返ってくる答えが日によって違う」。実際に困っていたのは揺れそのものではなく、揺れが正常なのか異常なのかを言えないことだった
  • 今のダイヤの問題: 応答は本文と引用規程番号の両方を受け取っていたのに、番号を一度も参照していなかった。半年前に組んだときは、まず答えが届くことが仕事であり、当時の条件ではその組み方で合っていた
  • 打った一手: 出力の契約を、揺れてよい部分(回答本文)と揺れてはいけない部分(引用規程番号)に割った。判定は二段——揃っているか(応答だけを見れば決まる)と、実在するか(応答の外にある台帳と突き合わせて初めて決まる)。これにより、契約違反の応答が利用者まで素通りする経路が構造的に無くなった
  • 配線した場所: 起動時の組み立て(build_desk)。台帳を読んで契約を作り、窓口へ渡している。ask を呼ぶ側のコードは変更していないが、配線そのものは消えていない
  • 保証しないこと: 回答本文の内容が業務的に正しいこと。引かれた規程が、その問い合わせに対して適切であること。台帳そのものが最新であること。今夜の実演が示したのは、一つのプロセスの中で一件の応答が契約に合っているかまで。契約違反のとき利用者の画面に何を出すかも、窓口を持つ側の判断として今夜は決めていない
  • シミュレータ結果: 八本、〇・〇〇八秒。言い回しの違う三通りの応答で判定の種類が一つしか無いこと、契約に適合するあいだ組み直す前と後で回答が変わらないこと、そして組み直す前の窓口が契約違反の応答を素通しすることを確かめた
  • 次の当直への申し送り: 台帳が古かった場合、判定は合格する。台帳の鮮度は契約の外にある

動いてはいるけれど、それが正常なのか異常なのか言えない——そういうシステムに、心当たりはありませんか。

何を異常と呼ぶかを決めていなければ、異常が出ても気づけません。どこに線を引けばいいか分からないときは、Meetsource の相談窓口から声をかけてください。

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