7月13日、OpenAIの新しいプロンプティングガイドを扱ったニュースが出た。「考え込みすぎず、結果から始める」という話だ。これが意味するのは、長い指示を書く技術より先に、完了の定義を固定するほうが実務では効く、ということだと思う。
自分はリリースノートのような小さな生成でも、背景や注意点から順に書いていた。さっき同じ依頼を「最終成果物」「完了条件」「入力」「制約」の順に組み替えてみたら、確認したいポイントが先に見える。地味だけど、レビューで戻す回数に直結する差だった。
結論から書くと、プロンプトを短くする前に「何が返れば完了か」を文字列として検査できる形にするのがいい。モデルに渡す文章と、呼び出す側の検査を同じ契約から作る。
先に置くのは答えではなく完了条件
たとえば「git diff からリリースノートを書いて」は、実装する側には情報が足りない。JSONなのかMarkdownなのか、変更のない部分を補ってよいのか、分からないときに推測してよいのか。この空白をモデルが埋めると、もっともらしい余計な一文が混ざる。
ここでは release-note.json を最終成果物に決め、キーを3個に限定する。risks は空配列を許可する。リスクがなかったことまで無理に文章にさせないためだ。
Pythonでプロンプト契約を作る
次のコードはPython 3.9で実行した。外部パッケージは使っていない。LLM APIを呼ぶ直前に build_prompt() を呼び、返答を受けた直後に validate_release_note() を呼ぶ想定だ。
from dataclasses import dataclass
from typing import List
import json
BANNED_GOALS = ("いい感じ", "適切に", "よしなに")
ALLOWED_KEYS = {"title", "summary", "risks"}
@dataclass(frozen=True)
class PromptContract:
artifact: str
done_when: List[str]
source: str
constraints: List[str]
def _required(label: str, value: str) -> str:
value = value.strip()
if not value:
raise ValueError(label + " を空にできません")
if any(word in value for word in BANNED_GOALS):
raise ValueError(label + " に曖昧語があります: " + value)
return value
def build_prompt(contract: PromptContract) -> str:
artifact = _required("artifact", contract.artifact)
source = _required("source", contract.source)
done_when = [_required("done_when", item) for item in contract.done_when]
constraints = [_required("constraints", item) for item in contract.constraints]
if not done_when:
raise ValueError("done_when を1件以上指定してください")
return "\n".join([
"# 最終成果物", artifact,
"", "# 完了条件", *["- " + item for item in done_when],
"", "# 入力", source,
"", "# 制約", *["- " + item for item in constraints],
"", "完了条件を満たす成果物だけを返してください。",
])
def validate_release_note(raw: str) -> dict:
note = json.loads(raw)
if set(note) != ALLOWED_KEYS:
raise ValueError("keys は title, summary, risks だけにしてください")
if not isinstance(note["title"], str) or not isinstance(note["summary"], str):
raise ValueError("title と summary は文字列にしてください")
if not isinstance(note["risks"], list) or not all(
isinstance(item, str) for item in note["risks"]
):
raise ValueError("risks は文字列の配列にしてください")
return note
contract = PromptContract(
artifact="release-note.json(JSONオブジェクト1件)",
done_when=[
"keys が title, summary, risks の3つだけである",
"risks は空配列を許可し、推測で項目を増やさない",
],
source="git diff --cached の出力を根拠にする",
constraints=[
"変更されていないファイルには触れない",
"根拠がない事項は unknown と書く",
],
)
print(build_prompt(contract))
print(validate_release_note(
'{"title":"設定を更新","summary":"timeoutを30秒に変更","risks":[]}'
))
try:
build_prompt(PromptContract("いい感じの要約", ["JSONを返す"], "diff", ["短く"]))
except ValueError as error:
print("ERROR:", error)
出力はこうなる。
# 最終成果物
release-note.json(JSONオブジェクト1件)
# 完了条件
- keys が title, summary, risks の3つだけである
- risks は空配列を許可し、推測で項目を増やさない
{'title': '設定を更新', 'summary': 'timeoutを30秒に変更', 'risks': []}
ERROR: artifact に曖昧語があります: いい感じの要約
BANNED_GOALS は万能ではない。チームでよく出る曖昧語を、失敗ログを見ながら2、3個だけ足すのが現実的だった。ここを辞書のように膨らませ始めると、今度は依頼そのものが書きにくくなる。
プロンプトと検査を分ける理由
このコードのポイントは、プロンプトが正しいから出力も正しい、と扱わないところにある。build_prompt() は依頼の抜けを呼び出し前に止める。validate_release_note() はモデルが余計なキーを返したときに、保存前で止める。
順番を変えるだけではなく、失敗を止める場所を二つに分けた。前者は人間が依頼を直す場所で、後者は再試行や人の確認へ戻す場所になる。特にエージェントにファイル更新やチケット作成をさせるなら、後段の検査なしで次のツールへ渡すのは危ない。
細かいけれど、set(note) != ALLOWED_KEYS は余計なキーと不足したキーを一度に弾ける。一方で、この例は空文字の summary までは弾かない。要約が空なら失敗にしたい業務では、not note["summary"].strip() の条件も追加する。契約は厳しいほどよいわけではなく、保存先が本当に必要とする条件だけを置く。ここを盛りすぎると、今度は正しい返答まで落とす。
OpenAIのガイドにある「結果から始める」は、結論を一行だけ先頭へ置く小技ではないはずだ。成果物、完了条件、入力の順で決めると、プロンプトは説明文からインターフェースに近づく。自分はまず、毎週繰り返す1本の自動化だけをこの形にして、失敗時に何を追加したくなったかを見るつもりだ。
おわりに
プロンプトを長文化しても、完了条件が曖昧ならレビューの手間は減らない。最終成果物を先に書き、機械で確かめられる条件を二つほど置く。生成後にも同じ条件を検査する。
この3点だけで、モデルの出力を「読んで判断するもの」から「通してよいか判定できるもの」へ寄せられる。LLMを業務フローに入れるとき、最初に育てるべきなのは凝った指示文よりこの境界だと思う。