実は、LLMの出力を整えるためにもう一度LLMを呼ぶのは、社内業務だと割に合わないことが多いです。
商談メモから「案件名・金額・確度」を拾わせ、スプレッドシートへ流す。ここで欲しいのは気の利いた言い換えではなく、3列が揃ったJSONです。前置きの「承知しました」やMarkdownのコードフェンスが混じっただけで、次の処理が止まります。
今朝、Claudeの出力に残る余計なトークンを別のLLMで片付けるツールを見かけました。文章を読みやすくする用途ならありです。ただ、列に流すデータまで再生成に任せると、金額や確度が書き換わった時に追えません。僕なら、整形はPython、内容の判断だけをLLMに任せます。
Q. プロンプトで「JSONだけ返して」と書けば足りない?
足りません。モデル側の設定や会話の流れで、```json の囲みや一言の説明は普通に混じります。出力をそのまま json.loads() に渡すと、たとえば次のエラーになります。
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
手元で同じ形のサンプルを流すと、前置きが付いただけでCSV化の処理は空振りします。モデルの答え自体は合っているのに、受け側が形式を決めていない。ここ、地味に効きます。
必要なのは、少しだけ受け取りを頑丈にすることです。ただし何でも救済すると壊れたデータまで通すので、列名と値は最後に検収します。
JSONを切り出して検収するPython
下のコードは標準ライブラリだけで動きます。標準入力にLLMの生の回答を渡し、JSON配列を見つけたら案件の3項目を確認します。余計な列、金額の文字列、A/B/C以外の確度はエラーにします。
import json
import sys
REQUIRED_KEYS = {"案件名", "金額", "確度"}
CONFIDENCES = {"A", "B", "C"}
def extract_json(text: str):
text = text.strip()
fence_start = text.find("```")
if fence_start != -1:
first_newline = text.find("\n", fence_start)
closing_fence = text.find("```", first_newline + 1)
if first_newline == -1 or closing_fence == -1:
raise ValueError("コードフェンスが閉じていません")
if text[closing_fence + 3 :].strip():
raise ValueError("コードフェンスの末尾に説明文が残っています")
text = text[first_newline + 1 : closing_fence].strip()
decoder = json.JSONDecoder()
found_json = False
for start, char in enumerate(text):
if char not in "[{":
continue
try:
value, end = decoder.raw_decode(text[start:])
except json.JSONDecodeError:
continue
found_json = True
if not text[start + end :].strip():
return value
if found_json:
raise ValueError("JSONの末尾に説明文が残っています")
raise ValueError("JSONを見つけられません")
def validate(items) -> None:
if not isinstance(items, list):
raise ValueError("JSONの先頭は配列にしてください")
for index, item in enumerate(items):
if not isinstance(item, dict):
raise ValueError(f"item[{index}] がオブジェクトではありません")
if set(item) != REQUIRED_KEYS:
raise ValueError(f"item[{index}] のキーが違います: {sorted(item)}")
if not isinstance(item["案件名"], str) or not item["案件名"].strip():
raise ValueError(f"item[{index}] の案件名が空です")
if isinstance(item["金額"], bool) or not isinstance(item["金額"], int):
raise ValueError(f"item[{index}] の金額は整数にしてください")
if item["金額"] < 0:
raise ValueError(f"item[{index}] の金額が負です")
if item["確度"] not in CONFIDENCES:
raise ValueError(f"item[{index}] の確度はA/B/Cにしてください")
try:
result = extract_json(sys.stdin.read())
validate(result)
except ValueError as error:
raise SystemExit(f"エラー: {error}")
print(json.dumps(result, ensure_ascii=False, indent=2))
図にすると、LLMは候補を出す担当で、採用の可否はPython側です。ここを分けると、シートに入った数字がどこで壊れたかを見つけやすくなります。
Q. 実際に何が通り、何を止める?
次のように、回答の前に短い文章やコードフェンスがあっても、配列の中身が正しければ通ります。
python3 check_llm_json.py <<'EOF'
抽出結果です。
```json
[
{"案件名": "展示会ブース", "金額": 120000, "確度": "A"},
{"案件名": "提案資料作成", "金額": 48000, "確度": "B"}
]
```
EOF
出力はこうなります。
[
{
"案件名": "展示会ブース",
"金額": 120000,
"確度": "A"
},
{
"案件名": "提案資料作成",
"金額": 48000,
"確度": "B"
}
]
一方、"金額": "120,000円" は通しません。見た目は親切でも、集計用のセルには文字列です。ここで落として、プロンプトを直すか人が確認した方が早いです。末尾に「以上です。」が付いたケースも止めます。途中まで正しいJSONを勝手に採用すると、次回の障害が見えなくなるからです。エラー文と元の回答を一緒に残すと、直す場所もすぐ分かります。
check_llm_json.py に保存したら、通ったJSONだけをGASやCSV出力の入力に渡します。案件名や確度の候補を変えたくなった時は、REQUIRED_KEYS と CONFIDENCES を先に直せばよいです。受け側のルールがコードに残ります。
Q. これでプロンプトは雑でいい?
そこは逆です。プロンプトにも「配列だけを返す」「金額は整数、円記号とカンマなし」「確度はA/B/C」と書きます。Pythonの検収は、プロンプトを省略するためのものではありません。外れ値を早く発見するための最後の関門です。
入力元が商談メモなら、案件名の重複や金額の妥当性まではこのコードで決めません。そこは元のメモと見比べる仕事です。形式チェックと内容確認を一緒にすると、どちらも曖昧になります。
で、現場でどう使うか
LLMに再整形を頼む前に、まず受け取り側の条件を固定します。案件抽出のように次工程が決まっている仕事では、きれいな文章より壊れないJSONの方が価値があります。
この小さな検収を挟むだけで、スプレッドシートの集計が「たまに変な行を含む」状態から抜けられます。LLMには候補を出させる。数字と列の責任は、手元のコードで持つ。営業や企画の現場では、この分け方がいちばん再現しやすいです。