先週、30分の会議の文字起こしをChatGPTにそのまま貼って「決定事項と宿題を出して」と頼みました。返ってきた要約はきれいでした。決定が3つ、担当と期限も付いている。そのまま議事録としてチームに流しました。
問題は翌日に出ました。会議の後半で決めた「販促キャンペーンの予算枠」が、要約からまるごと抜けていたんです。
エラーは一度も出ていません。ここが厄介なところでした。
なぜ「エラーなし」で答えが欠けるのか
長文をAIに丸ごと投げる仕事は、たいてい途中で雑になります。要約・議事録・長いメールへの返信、規程の読み解き。入力が長いほど、出力もどこかで詰まる。
詰まる正体は出力側の上限です。入力(プロンプト)はモデルの窓に収まっても、生成できる出力トークンには別の上限があります。そこに当たると、モデルは謝りも止まりもせず、書けたところまでの文章をそのまま返してきます。
実際に手元で起こしてみました。出力上限をわざと16トークンに絞って、桃太郎のあらすじを300字で頼みます。
import json, urllib.request
from pathlib import Path
def _key():
for line in Path(".env").read_text().splitlines():
if line.startswith("OPENAI_API_KEY="):
return line.split("=", 1)[1].strip()
body = {"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "日本語で、桃太郎のあらすじを300字で説明して。"}],
"max_completion_tokens": 16}
req = urllib.request.Request("https://api.openai.com/v1/chat/completions",
data=json.dumps(body).encode(),
headers={"Authorization": "Bearer " + _key(), "Content-Type": "application/json"},
method="POST")
r = json.load(urllib.request.urlopen(req, timeout=60))
ch = r["choices"][0]
print("finish_reason =", ch["finish_reason"])
print("本文 =", ch["message"]["content"])
返ってきた中身がこれです。
finish_reason = length
本文 = 桃太郎は、日本の昔話で、桃の中から生ま
「桃の中から生ま」で文章が止まっています。でも例外は飛んでいません。finish_reason が length になっているだけ。普段ここを読んでいる人は少ないと思います。僕も読んでいませんでした。
つまり最初の議事録で起きたのも同じことです。後半に差しかかったあたりで出力上限に届き、書けたところまでを「完成した要約」の顔で返していた。きれいに見えたのは、欠けた部分が最初から無かったように整っていたからでした。
まず、切れたかどうかを必ず確かめる
対策は二段です。一つ目は単純で、finish_reason を毎回見る。length だったら「これは未完成」と扱って、処理を止めるか上限を上げる。受け取った文字列をそのまま信用しないことが出発点です。
def ask(prompt, max_tokens=1000, model="gpt-4o-mini"):
body = {"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_completion_tokens": max_tokens}
req = urllib.request.Request("https://api.openai.com/v1/chat/completions",
data=json.dumps(body).encode(),
headers={"Authorization": "Bearer " + _key(), "Content-Type": "application/json"},
method="POST")
r = json.load(urllib.request.urlopen(req, timeout=120))
ch = r["choices"][0]
if ch["finish_reason"] == "length":
raise RuntimeError("出力が途中で切れた。max_tokensを増やすか分割する")
return ch["message"]["content"]
これで「気づかないまま欠けた要約を流す」事故は防げます。ただ、上限を上げるだけでは足りない場面があります。元の文章が本当に長いと、出力をどれだけ広げても一度では収まりません。
長い入力は、割って・部分ごとに要約して・つなぐ
二つ目が本体です。長文は先に分割してから、部分ごとに要約し、最後に部分要約をまとめ直す。
割るときに一つだけ気をつける点があります。区切りで文脈が切れないように、隣り合うチャンクを少しだけ重ねること。重ねないと、ちょうど境目にあった「担当は田中、期限は月末」のような一文が、前にも後ろにも属さず消えます。
def split_text(text, max_chars=1500, overlap=150):
if len(text) <= max_chars:
return [text]
paras, chunks, buf = text.split("\n\n"), [], ""
for p in paras:
if len(buf) + len(p) + 2 <= max_chars:
buf = (buf + "\n\n" + p) if buf else p
else:
if buf: chunks.append(buf)
buf = p
if buf: chunks.append(buf)
if overlap and len(chunks) > 1:
chunks = [chunks[0]] + [chunks[i-1][-overlap:] + "\n\n" + chunks[i]
for i in range(1, len(chunks))]
return chunks
段落(空行区切り)を優先して割っているので、文の途中でぶつ切りになりにくい。overlap=150 で前のチャンクの末尾150文字を次の頭にくっつけます。
要約の流れはこうです。
def summarize_long(transcript):
parts = split_text(transcript, max_chars=1500, overlap=150)
partials = [ask(f"次の議事録の一部から「決定事項」と「宿題(担当・期限つき)」だけを箇条書きで抜き出して。\n\n{c}")
for c in parts]
return ask("次は同じ会議の部分要約です。重複を除いて整理し直して。\n\n"
+ "\n\n---\n\n".join(partials))
擬似的に作った2000字ほどの議事録(議題5つ・各議題に決定と宿題)で実行すると、2チャンクに割れて、最終的にこう返りました。
### 決定事項
- 新サービスの料金体系は来月から進める。
- 採用計画は来月から進める。
- オフィス移転は来月から進める。
- 販促キャンペーンは来月から進める。
- 在庫管理ツール導入は来月から進める。
### 宿題(担当・期限つき)
- 新サービスの料金体系の担当は田中、期限は月末。
- ...(議題ごとに同様)
5つの議題が一つも欠けずに残り、重複もなく整理されています。最初に消えた「販促キャンペーン」も、ちゃんと入っています。
つまずいた所
一つ。overlap を足すと、チャンクが max_chars をわずかに超えます。手元の検証でも900字上限のはずのチャンクが928字になりました。上限ぎりぎりで運用するなら、重ねる分を見込んで max_chars を少し小さめに取ると安全です。ここ地味に効きます。
もう一つ。部分要約を一つにまとめる最後の呼び出しでも、部分要約が多いと出力が切れます。だから統合のステップも ask を通して finish_reason を見る。検知の網は最後まで外さないことです。
引数名にも注意がいります。新しめのモデルでは max_tokens ではなく max_completion_tokens を使います。古い名前のままだとエラーになるか無視されることがあります。
で、現場でどう使うか
長い文章をAIに丸ごと投げて返ってきた答えは、まず「全部入っているか」を疑う。これを習慣にするだけで、議事録や問い合わせ要約の取りこぼしはかなり減ります。鍵は finish_reason ひとつ。きれいに見える要約ほど、欠けていても気づけないからです。
割って・部分ごとにやって・つなぐ。この三段は要約に限らず、長文の翻訳や項目抽出にもそのまま使い回せます。今日の自分の仕事で一番長いテキストを思い浮かべて、それが一度の呼び出しに収まるか。怪しいと思ったら、もう分割の出番です。