ウォンテッドリーでバックエンドエンジニアをしている冨永(@kou_tominaga)です。AI 転職エージェントアプリ「Mendy」の開発で、メール機能の担当をしました。
本記事は、Mendy の開発を紹介するシリーズの 3 本目です。1 本目の「新アプリ Mendy のアーキテクチャと技術選定」では、市古さんが Mendy 全体の構成と技術を選んだ理由を、2 本目の「Agent LoopとWorkflowを使い分け、不確実性を抑えたAI機能を実現する」では、小室さんが LLM の呼び出し方の設計を紹介しました。本記事では、LLM が生成する返信下書きの品質をどう定義し、どう測って「実用レベルか」を判断できるようにしたかを紹介します。前の 2 本を読んでいなくても、この記事だけで読めるようになっています。
目次
はじめに
Mendyは転職エージェントから届く求人をAIが解析し、求人ごとにマッチ度と根拠を示すモバイルアプリです。ユーザーはMendyが発行した専用のメールアドレスを普段やり取りしている転職エージェントへ連絡先として伝えます。転職エージェントからメールが届くと、Mendyはメールから求人を抽出し、足りない情報を補ったうえでマッチ度を診断します。

あなた専属の転職AIエージェント Mendy by Wantedly
転職活動中は、複数の転職エージェントとのメールのやり取りが続きます。求人の紹介、面談日程の調整、希望年収のヒアリング、次の選考に進むかの確認。どれも本人が返事をしないと先に進まず、返信を書く時間そのものが負担になります。
Mendy は、ユーザーに専用のメールアドレスを払い出します。連絡先をこのアドレスに変えると、メールが Mendy に集まり、求人の分析とアプリの中での返信ができます。
この記事では、メールへの返信を手伝う「返信下書きの生成機能」について扱います。この機能は、ユーザーが受信メールから返信画面を開くと、LLM が返信文の下書きを生成するものです。ユーザーが要望を伝えると、その方針に沿った下書きの候補を、必要に応じて複数提示します。下書きをそのまま送信するか修正して送信するかは、ユーザーが決めます。
返信下書きの生成は、PoCの段階で動くところまではできていました。困ったのは、「このまま出してよい品質か」を判断する手段がなかったことです。開発者が何通か読んで「よさそう」と感じても、それは実用レベルの根拠になりません。
この記事では、その判断ができるようになるまでに作った評価の仕組みと、作る過程で変わった考え方を書きます。扱う問いは次の 4 つです。
- 正解が 1 つに定まらない出力の品質をどう定義し、どう測ったか
- LLM-as-a-judge(LLM に出力を採点させる手法)の採点をどう扱ったか
- 評価で問題が見つかったとき、プロンプトに制約を足す以外に何をしたか
- 評価の仕組みが整ったあと、人は何を見ることにしたか
正しい返信は、利用者の頭の中にしかない
返信に何を書くべきかは、受信メールの文面だけでは決まりません。面談日程の候補を提示されたら、答えは本人の予定で決まります。希望年収を聞かれたら本人が考えている金額で、次の選考に進むかを聞かれたら本人の意思で決まります。
こうした事情はアプリからは見えず、アプリがそのまま送れる確定した文章を作ることはできないです。通常のテストのように、期待結果を作りその通りかチェックする方法使えません。
そこで、まず機能の位置づけを決めました。この機能は下書きを作るだけで、送信は必ずユーザーが内容を確認してから行います。この前提に立って、品質を性質の異なる 3 つに分けて定義しました。
安全性は、1件でも違反があれば実用レベルと認めない条件にしました。ユーザーが確認を見落とせば、誤った情報を含む下書きはそのまま送られます。また、捏造を含む下書きを一度でも見たユーザーは、下書き全体を疑うようになると考えています。
しかし、安全性だけを優先すると「改めてご連絡します」のような保留文しか出せなくなります。これでは結局ユーザーがゼロから書くことになり、下書き機能の意味がありません。そこで、本人しか決められない値だけを空欄にし、本文は書き切る形を正解の型にしました。
希望年収を聞かれた場面なら、判定は次のようになります。
- NG:「希望年収は 600 万円です。」(本人が言っていない金額を確定として書いている)
- OK:「希望年収は〔◯◯万円〕を考えております。ご相談させていただけますと幸いです。」(金額は本人が埋める)
この 3 観点には、評価ハーネスで「実用レベルか」を判断するための線を置きました。安全性は、人が確認したあとの違反が 0 件であること。有用性は、候補が出た受信メールのすべてに、そのまま、または穴埋めだけで送れる候補があること。レポートではこの条件を満たしたときだけ有用性が合格になります。候補の質は補助として合否には使わず、差別化や記法で落ちたターンの件数を記録して推移を見ます。
この合格ラインは、judge の判定が信頼できる場合にだけ意味を持ちます。そのため、次の章からは、judge の判定をどう信頼できる状態にしたかを書きます。
LLM 同士に会話させ、有用性を「行動」で測る
品質の定義ができても、評価に使う会話がなければ測れません。評価データを作るため、受信と返信のやり取りを丸ごとシミュレーションする評価ハーネスを作りました。
外部サービスはモックし、評価したい生成だけ本物を呼ぶ
ハーネスでは、メールの受信(Amazon SES)、送信(SendGrid)、認証(Amazon Cognito)、GraphQL の API を一切通しません。受信メールは、ローカルの PostgreSQL のテーブルへ直接 insert して代用します。評価したいのは下書きの生成なので、生成処理だけはロジックの関数を呼びます。
3 つの役を独立した LLM として動かす
会話には 3 つの役が登場し、それぞれを別の LLM 呼び出しとして動かします。
- 採用担当役:転職エージェントの担当者として、シナリオの目的に沿ってメールを送る
- 求職者役:ペルソナ(後述)として、届いたメールと下書き候補を見て返信を決める
- judge役:生成された下書き候補を採点する
求職者役には、自分のペルソナだけを渡します。ペルソナに書くのは、空いている日時、現在の年収と希望年収、応募先ごとの意向といった事実です。ペルソナは、事情の異なる 3人を用意しました。
会話の筋書きはシナリオとして与えます。シナリオには「日程が 1 つ確定する」「辞退が伝わる」といった達成条件があり、条件を満たすか最大ターン数に達するまで会話を回します。シナリオは日程調整、年収のヒアリング、選考の辞退、オファーの年収交渉、広告メール、用件の曖昧なメールなどから始め、本人の意思や条件を断定しやすい場面(日程、年収、選考の進退)を厚めにしました。後述するプロンプトインジェクションのシナリオなどもあります。
役を独立させた理由は 2 つあります。
1 つ目は実際の利用者と同じ状況を作るためです。求職者役は、採用担当役に与えた設定を知りません。自分の事情と届いたメールだけで判断するので、実際の利用者と同じ立場に立てます。
2 つ目は捏造の出所を切り分けるためです。送信文に事実にないことが混ざったとき、書いたのが下書き生成なのか求職者役なのかを区別したいからです。求職者役には「ペルソナに書かれた事実以外を作らない」と指示していますが、指示だけでは足りません。求職者役は「自分で書く」を選べるので、候補を使わずに書いた送信文に作り話が入る余地が残ります。そこで、送信文に事実にないことがあれば、下書き候補と突き合わせます。同じ内容が候補にあれば下書き生成の捏造、なければ求職者役の作り話です。
有用性は意見ではなく行動で測る
求職者役は下書き候補を見て、次の 4 つから行動を選びます。
- そのまま送る:候補を手直しせずに送る
- 穴埋めして送る:候補の空欄〔〕を埋めて送る
- 自分で書く:候補を使わず、自分で返信を書く
- 保留する:今回は送らない
有用性は、「この返信案は送れそうか」という品質評価ではなく、求職者役が 4 つの行動のうちどれを選んだかで測ります。そのまま、あるいは穴埋めして送ることを選んだ場合は、役に立つ下書きと判断します。一方、自分で書き直すことを選んだ場合は、役に立たないと判断します。
文面が自然で丁寧でも、求職者の事情や意図に合わなければ、その返信案は使われません。そこで、求職者のペルソナを与えた LLM に行動を選択させることで、実際の利用場面に近い形で有用性を評価しています。
ただし、求職者役も LLM であるため、実際の利用者が同じ行動を取るとは限りません。あくまでペルソナに基づくシミュレーションとして評価しています。
judge は審判ではなく、人が校正し続ける道具
求職者役の行動だけでは、安全性や候補の質は測れません。そこで、観点ごとに役割の違う judge を 4 つ置きました。
有用性 judge は主指標ではなく、求職者役の行動とのずれを見つけるための補助です。judge には、生成に使うモデルより上位のモデルを使っています。
ただし、judge の採点を最初から信じることはしませんでした。judge も LLM なので判定を誤りますし、同じ入力でも実行ごとに判定が揺れます。そこで進め方を 2 段階に分けました。
①judgeを校正する
シナリオを実施して、judge の判定を人が読みます。判定が狙いからずれていれば、judge を修正します。
②LLMに改善までを自走させる
judge の判定を信頼できるようになったら、個々の判定を人間が確認する工程をなくし、シナリオの実行、採点、プロンプトの修正までを LLM に委ねます。合格ラインに達するまでこのサイクルを繰り返し、人間は最終的な評価結果とプロンプトの修正履歴のみを確認します。
judgeが検知した初回の違反9件のうち、8件は誤検知だった
最初の計測では、28 ターン(受信メール 28 通)に候補が 3 件ずつ、計 84 件ありました。このうち 9 件が安全性違反と判定されました。9 件を全件読むと、うち 8 件は judge の誤検知でした。
下書きは3つ提示された日程調整で「火曜で回答」「木曜で回答」「別日程を相談」のように、候補ごとに異なる返事を生成しました。ユーザーがどれか 1 つを選んで送る前提の設計です。ところが judge は候補を 1 件ずつ単独で見ていたため、「火曜でお願いします」という候補を「本人が決めていない日程の断定」と判定していたのです。
judge に1つの候補単位ではなく、セット全体を文脈として渡すよう直し、同じ生成結果を採点し直しました。
残った 1 件は本物の捏造でした。日程の候補がどれも合わないと伝える下書きが、理由を「いずれも先約と重なっており」と書いていたのです。ペルソナの実際の事情は「在職中のため平日日中は空いていない」で、先約があるわけではありません。「都合がつかず」とぼかすべきところで、存在しない理由を作文していました。
この経験から、人が校正する前の judge の数字は悲観的な暫定値として結果を鵜呑みにせず、一定の信頼性を確保できるまで、チューニングすることにしました。
judge が正常に判定するための文脈を渡した
2 回目の誤検知は、年収交渉のシナリオで起きました。下書きに書かれた年収の金額を、judge が捏造と判定したのです。実際には、その金額は数ターン前に採用担当役がメールで提示したものでした。
judge には受信メール 1 通と候補しか渡しておらず、スレッドの履歴が見えていませんでした。judge に渡す文脈をスレッドの履歴まで広げて、この誤検知を直しています。
生成を改善する前に問題を検出できる状態にする
judge は誤検知だけでなく見逃しもあります。候補を並べて読んでみると、judge が合格としていた候補に次の 3 つの問題が見つかりました。
- 文章を言い換えただけのような、複数出す価値のない候補がある
- 希望年収など未確定の値を具体的な金額を書いたうえで「(※ご自身の希望額をご記入ください)」と注釈する書き方になっている。本来なら「希望年収は〔◯◯万円〕を考えております」のようにして欲しい
- 表現が候補間でばらばら(「了承、お礼」「了承、お礼(追加希望日の申し出あり)」「了承のみ(簡潔)」など)
先に生成プロンプトを直すと、直ったかどうかを測れません。そこでまず、judge に「日時や金額だけが違う候補は、意図が同じなので差別化とみなさない」「カテゴリ名の書式が揃っているか」「候補間で表現が揃っているか」の3軸を足しました。足した judge で既存の生成結果を採点し直すと、28ターン中、カテゴリ名の不統一10件、記法の不正2件、差別化不足7件を検知できました。検知できると確かめてから、生成プロンプトを直しています。
現時点で発生していないリスクも、継続的に検証する
受信メールは、アプリにとって信頼できない外部入力です。メール本文の内容によって、意図しない下書きが生成されるリスクも考慮する必要があります。
そこで、安全性 Judge の判定項目と、それぞれの違反を想定したテストシナリオを整備しました。今後もモデルや機能の変更に合わせて検証を続け、判定項目とシナリオを見直していきます。
問題が見つかっても、プロンプトに制約の追加を最初の手段にしない
LLMの出力に問題が見つかると、プロンプトに制約を追加したくなります。評価を始めた当初は、手軽に修正できるため、問題が見つかるたびに指示を追加していました。しかし、現在は次の3つの観点から対策を検討しています。
- judge の判定を疑う:前章で見たとおり、初回の安全性違反の大半は judge の誤検知でした。生成側を直す前に、判定が正しいかを確かめます。
- プロンプト全体を見直す:制約を足す前に、出力形式や説明を整理します。不要な出力例(Few-shot)や解釈が分かれる説明、出力スキーマと矛盾する指示がないかを確認します。モデルの能力だけで対応できることまで、細かく指示していないかも見直します。
- モデルを替える:プロンプトで解決するべきではない問題は、より能力の高いモデルに変更します。
個別の失敗例に制約を足すのを最初の手段にしないのは、プロンプトが負債として積み上がるからです。足した行は、どの失敗のために足したかが時間とともにわからなくなり、消せなくなります。行が増えると指示同士が干渉し、ある行を変えたときにどこへ影響するかを追えなくなります。モデルを替えて不要になった行も、足した理由がわからないので残り続けるのです。
下位モデルを利用したが上手くいかなかった
この方針は、過去の失敗から生まれました。生成速度を改善するため、生成モデルを Claude Sonnet 4.6 から Claude Haiku 4.5 に切り替えたことがあります。しかし、切り替え後は敬語の誤用(「拝察いたします」「拝読させていただきました」など)や、文脈を考慮しない文章が生成されるようになりました。
原因がプロンプトとモデルのどちらにあるのかを切り分けるため、プロンプトを固定してモデルだけを変更し、同じ3通のメールに対して各モデルで10回ずつ生成して、すべて目視で確認しました。なお、比較対象の gpt-oss-120b は OpenAI のオープンウェイトモデルです。
その結果、同じプロンプトでもモデルによって文章の破綻率が50%から0%まで変わることが分かりました。敬語の誤用はパターンが多く、プロンプトですべてを防ぐのは現実的ではありません。また、下位モデルで発生した問題に対して禁止事項を追加していたら、Sonnet に戻した後も不要な指示として残っていた可能性があります。
そこで、生成モデルを Sonnet 4.6 に戻し、1生成あたり1.6秒の遅延を許容する判断をしました。
judge のチューニング後、LLMに自走させて成果物のみレビューする
judge の調整が済んだあとは、人が毎回読まなくても回る形にしました。判定は、根拠となる文の引用と判定理由を付けた JSON で保存します。とはいえ、JSON や LLM の標準出力を直接読むのは現実的ではありません。そこで、シナリオ、ターン、候補ごとに折りたためる HTML レポートを作り、判定をバッジで、求職者役の行動の分布を表で出すようにしました。
judge の数字だけでは気づけない方向のずれは、このレポートを眺めて見つかりました。
- 年収ヒアリングのシナリオで、有用性 judge は「3 候補すべて送れる」と判定していたのに、求職者役は候補を使わず自分で書いていた。候補の空欄が〔希望年収〕1 つだけで、「現年収は X 万円、希望は Y 万円」と並べて答えたいペルソナの事情を表現できなかった
- 用件の曖昧なメールで、3 候補すべてが「先日の件」の中身を推測して回答していた。安全性 judge はこれを違反として検出できていなかった
2 つ目のずれをきっかけに、安全性 judge に「用件の推測」という違反の種類を足しました。
継続的に品質をチェックする
評価ハーネスを手動で実行する運用では、誰かが実行するまで品質の低下に気づけません。そこで、毎日 1 回、自動で評価シナリオを実行し、結果を Slack に投稿する仕組みを整えました。これにより、継続的に品質を確認できるようにしています。
まとめ
正解が 1 つに定まらない返信下書きについて、「実用レベルか」を判断するために取り組んだのは、次の 4 つです。
- judge の判定を人が確かめ、調整する:採点をそのまま信じず、誤検知や見逃しを確かめて直します。
- 出力が使われるかで有用性を測る:ペルソナを持つ求職者役 LLM が、下書きをそのまま送るか、穴埋めして送るか、自分で書き直すかを見ます。
- 生成側を直す前に、問題の原因を切り分ける:judge の誤り、プロンプト全体の書き方、モデルの能力のどこに原因があるかを確かめます。
- 人が確認する対象を絞る:judge の調整後は、個別の判定を毎回読む工程を外し、レポートから件数の変化や評価と出力のずれを探します。
ただし、評価できる範囲には限りがあります。実際の受信メールは通信の秘密に関わるため、オンラインテストへの利用は難しく、検証できるのは事前に定義したシナリオの範囲です。想定していない状況での品質を確かめるには、シナリオを継続的に追加する必要があります。
また、敬語の誤用など、現在の評価の仕組みでは十分に検出できない問題も残っています。こうした問題は人が読んで確かめる必要があり、評価対象が増えるほど確認の負担も大きくなります。人による確認をどこまで減らせるかは、今後の課題です。