0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ChatGPT APIで長文要約が途中で切れる:finish_reasonで検知して分割する

0
Posted at

先週、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_reasonlength になっているだけ。普段ここを読んでいる人は少ないと思います。僕も読んでいませんでした。

つまり最初の議事録で起きたのも同じことです。後半に差しかかったあたりで出力上限に届き、書けたところまでを「完成した要約」の顔で返していた。きれいに見えたのは、欠けた部分が最初から無かったように整っていたからでした。

まず、切れたかどうかを必ず確かめる

対策は二段です。一つ目は単純で、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 ひとつ。きれいに見える要約ほど、欠けていても気づけないからです。

割って・部分ごとにやって・つなぐ。この三段は要約に限らず、長文の翻訳や項目抽出にもそのまま使い回せます。今日の自分の仕事で一番長いテキストを思い浮かべて、それが一度の呼び出しに収まるか。怪しいと思ったら、もう分割の出番です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?