行動ログからユーザーの職業を動的判定するAI実装(Groqで原価を抑える)
C言語一筋でやってきて、副業でSaaSを1人で作ることになった人向けの記事。
自分は組み込み系でC言語を23年やってきて、Web系のAPI設計もLLM活用もほぼ独学でやった。
「職業を選択させるのではなく、行動ログから推測する」機能を作ったときにハマった点をまとめる。
同じように「ユーザー属性をAIで動的に判定したいが、GPT-4だと原価が厳しい」という悩みを持つ人には、そのまま使える内容だと思う。
結論から
行動ログ(入力テキスト・記録頻度・使う単語)を蓄積し、一定件数たまった時点でLLMに投げて職業を推定させる。
モデルはGroq(Llama系)を使うことで、GPT-4o相当の判定精度を保ちつつ、1判定あたりのコストを1/20程度に抑えられた。
判定はリアルタイムではなく非同期バッチにして、ユーザー体験を損なわずにAPIコールの頻度を絞るのがポイントだった。
これで月間アクティブ数千人規模でも、AI判定機能の原価が月数千円で収まる設計にできた。
なぜこの問題が起きるのか
職業判定をユーザーに手動で選ばせる設計(D&D型・プルダウン型)は実装が楽だが、離脱率が高い。
自分が最初に作ったプロトタイプでは、オンボーディングで「あなたの職業を選んでください」という画面を挟んだところ、そこで離脱するユーザーが体感で3割近くいた。
理由は単純で、副業ユーザーは本業と副業で職業が2つあったり、「会社員だけどエンジニア」みたいに一言で言えない人が多いからだ。
かといって職業判定を完全放置すると、パーソナライズができずアプリの価値が半減する。
ダイエット記録なら「食事」の話が多いユーザー、副業commitなら「コード」「提案」の話が多いユーザー、というように行動ログには職業のヒントが大量に含まれている。
これを都度LLMに投げて判定させれば自然な体験になるが、素直にGPT-4クラスのモデルで毎回判定すると、ユーザー数が増えるほど原価が線形に膨らんでいく。
自分の試算では、月間1000人がそれぞれ1日3回ログを書くとして、GPT-4o(当時の価格)で毎回職業判定を回すと月あたりのAPIコストが7万円を超えた。
これはSaaSの月額980円プランではまず赤字になる水準で、ここで一度設計をやり直すことになった。
解決手順
ステップ1:判定タイミングを「都度」から「閾値到達時」に変える
まず変えたのはトリガーの設計。
ログを書くたびに判定するのではなく、未判定のログが5〜10件たまった時点でバッチ的に1回だけAPIを叩く方式にした。
これだけでAPI呼び出し回数が体感で1/5〜1/8になる。
判定が確定した後も、行動パターンが変わった(例えば副業の話がぱったり減って運動の話が増えた)場合に再判定するトリガーを別途持たせている。
再判定の閾値は「直近20件のログのうち、以前と違うカテゴリの単語が7割を超えたら」という単純なルールベースにして、ここはLLMを使わずコストゼロで判定している。
ステップ2:モデルをGroq(Llama 3.1 70B等)に切り替える
職業判定は「厳密な推論」よりも「傾向のパターンマッチ」に近いタスクだと自分は捉えている。
実際に同じプロンプト・同じログサンプルでGPT-4oとGroq上のLlama系モデルを比較したところ、6職業分類(自分のプロダクトでは会社員・フリーランス・経営者・学生・主婦主夫・その他の6分類)の一致率は9割前後で大きな差が出なかった。
Groqはトークン単価が安いだけでなく、レスポンス速度も速い。
バッチ処理なので速度自体はそこまで重要ではないが、開発中の試行錯誤(プロンプトを直して再実行)の回数を稼げたのは地味に助かった。
ステップ3:プロンプトは「分類」に特化させ、出力形式を固定する
自由記述で答えさせるとパースに失敗することがあったので、出力はJSON形式に固定した。
Cで長年組み込み系のプロトコル処理をやってきた身としては、パース側で異常系(想定外のフォーマット)をどう弾くかが一番落ち着く作業だった。
LLMの出力ゆらぎは、通信プロトコルのノイズと同じ扱いで設計すると事故が減る。
ステップ4:判定結果はキャッシュし、確信度が低い場合のみ再判定する
判定結果と一緒に確信度(0〜1のスコア)を出力させ、0.6未満なら「未確定」として次のログが溜まるまで判定を保留する設計にした。
これにより、情報が少ない段階で誤判定を確定してしまうケースを減らせた。
序盤に確信度の閾値を設けずに全部確定させていたときは、初日に数件しかログがないユーザーの職業判定が外れることが多く、この閾値導入後に体感の精度が上がった。
コード例
判定リクエストの骨組みを簡略化したもの(実際はエラーハンドリングやリトライ処理が入るが、ここでは省略する)。
import os
from groq import Groq
client = Groq(api_key=os.environ["GROQ_API_KEY"])
JOB_CATEGORIES = ["会社員", "フリーランス", "経営者", "学生", "主婦主夫", "その他"]
def classify_job(logs: list[str]) -> dict:
prompt = f"""
以下はユーザーの行動ログ(直近{len(logs)}件)です。
このユーザーの職業を次の6カテゴリから1つ推定してください。
カテゴリ: {", ".join(JOB_CATEGORIES)}
出力は以下のJSON形式のみで返してください。
{{"category": "カテゴリ名", "confidence": 0.0〜1.0の数値, "reason": "短い理由"}}
行動ログ:
{chr(10).join(logs)}
"""
res = client.chat.completions.create(
model="llama-3.1-70b-versatile",
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=200,
)
return parse_json_safe(res.choices[0].message.content)
def parse_json_safe(text: str) -> dict:
import json
try:
return json.loads(text)
except json.JSONDecodeError:
# モデルが余計な文章を前後に付けてくることがあるため、
# 中括弧の範囲だけ抜き出して再パースする
start = text.find("{")
end = text.rfind("}") + 1
return json.loads(text[start:end])
バッチトリガー側は、未判定ログの件数をカウントするだけの単純なジョブにしている。
def should_trigger_classification(user_id: str) -> bool:
unclassified_count = get_unclassified_log_count(user_id)
return unclassified_count >= 5
C言語しか書いたことがない人向けに補足すると、ここでやっていることは組み込みでいう「割り込みではなくポーリング+しきい値判定」に近い設計だと思う。
リアルタイム性を犠牲にする代わりに、処理コストと実装の単純さを取っている。
落とし穴と対処
一番ハマったのは、Groqのモデルが「JSON以外の説明文」を先頭に付けてくるケースだった。
temperatureを下げても完全には消えず、最終的には正規表現で中括弧の範囲を抜き出すガード処理を入れることで解決した。
ここはNDA範囲外の一般的な実装知見として書けるが、モデルのバージョンアップで挙動が変わることがあるため、パース処理は常に「失敗前提」で作るのがいいと自分は思う。
もう1つは、判定の確信度が低いまま放置されるユーザーが一定数出た点。
特にログの内容が「今日も疲れた」のような職業と無関係な内容ばかりのユーザーは、いつまでも「その他」判定のまま確信度が上がらなかった。
この層に対しては、判定を無理に確定させず「未判定のまま使える体験」を用意する方向で割り切った。
判定を急がせると、原則2で言うところの「読者を焦らせる設計」と同じ失敗をアプリでもやることになる。ここは自分のプロダクトの思想とも関わる部分だった。
コスト面では、Groqの無料枠・低価格帯モデルにも稀にレート制限がかかることがあり、バッチ処理のリトライ間隔を最初3秒固定にしていたら詰まった。
指数バックオフ(1秒→2秒→4秒…)に変更してからは安定して動いている。
関連ツール
この「行動ログから職業を動的判定する」仕組みは、実際に自分が個人開発しているALTERというプロダクトで使っている機能そのもの。
副業のcommitとダイエットの歩数を1つのキャラクターで育てる人生管理アプリで、職業をユーザーに選ばせず、行動ログから見えてくる形にしているのがこの記事の実装。
Free版はGroq、Pro版はClaude(Sonnet)でAI伴走の質を出し分けることで、原価と体験のバランスを取っている。
→ ALTER
まとめ
職業判定のような分類タスクは、必ずしも最上位モデルを使う必要はなく、Groqのようなコストの低いモデルでも実用レベルの精度が出ることが多いという実感がある。
判定タイミングをリアルタイムからバッチに変えるだけでもコストは大きく下がる。
確信度でしきい値を設けて未確定を許容する設計にすると、精度とユーザー体験の両方を守りやすい。
C言語での異常系設計の考え方は、LLMの出力ゆらぎへの対処にもそのまま応用できると自分は感じている。
著者:ぽん(@pon_freelance)
C言語実務23年、組み込み/制御系。
副業で技術記事販売と自作ツール販売をやっている。
書いているもの:
- ALTER - 副業の commit とダイエットの歩数を1つのキャラで育てる、焦らせない人生 OS(SaaS)
(その他:(なし))