2014 年から個人開発でアプリを公開し始めたとき、最初に直面した課題はサポートメールの返信でした。当時は壁紙系のアプリで App Store と Google Play 合わせて 5,000 万ダウンロードを超える規模に育っていたのですが、レビュー欄や問い合わせフォームから届く質問の多くは「同じ内容を、違う言葉で繰り返し聞かれている」という状態でした。返信のために夜遅くまで端末を開いていた時期があり、開発に向かう時間がじわじわ削られていく感覚は、いま振り返ってもよく覚えています。
10 年以上たって、Gemini API のような大規模言語モデルが手の届く価格で使えるようになりました。AdMob 収益で運用できる程度のコストで、過去には人手をかけられなかった一次対応が自動化できます。
このノートでは、公式の最小サンプルでは触れられない部分 — システムプロンプトの三層構造、Function Calling を使った FAQ 連携、エスカレーションの設計、そして実際に運用して気づいた落とし穴 — を一つずつ書き残します。「とりあえず動くサンプル」ではなく、「数ヶ月運用しても壊れない設計」を目指す方の参考になればうれしいです。
Gemini が変えた、カスタマーサポートの前提
数年前まで、サポートチャットボットといえばキーワードマッチングや決定木が主役でした。私自身も、シェル芸に近いルールベースのスクリプトを書いて、特定の単語が含まれていたら定型文を返すような実装をしていたことがあります。問題は「想定外の聞き方をされた瞬間に破綻する」ことです。たとえば「届かない」というキーワードに反応するルールを書いておくと、「届きました、ありがとうございます」という感謝のメッセージにも誤って返信してしまう、というような事故が起きます。
Gemini 3.1 Pro や Gemini 2.5 Flash の文脈理解力は、この前提を静かに塗り替えました。同じ「届かない」という訴えでも、「まだ届きません」と「もう一週間も連絡がありません」では緊張度が違います。後者には、より丁寧で、エスカレーション寄りの対応が必要です。こうした温度差を、システムプロンプトで明示的に教えられるようになったのが大きな変化です。文章の言外の意味を、ルールベースで書き起こすのは非常に大変ですが、Gemini なら自然言語のままシステムプロンプトに書けばよく、変更時の差分管理も非常に楽になりました。
実運用で見えてきた Gemini API の強みを、私の感覚で並べてみます。
文脈の繋がりを保ったままターンが続けられるため、ユーザーが情報を小出しにしても破綻しにくい
日本語と英語の混在した問い合わせを、言語切り替えなしに自然に処理できる
Flash 系の単価が十分に低く、月に数万件規模の対話でも個人開発レベルの収益で回せる
システムプロンプトの再学習が不要で、文面を書き換えるだけで方針を変えられる
逆に弱点もあります。とくに、ユーザーが感情的になった瞬間に過剰に丁寧な日本語を生成し、かえって距離感が出てしまうケースは何度も経験しました。「お客様のお気持ちは大変よく分かります。誠に恐れ入りますが…」という枕詞を繰り返した結果、相手から「もういいです」と打ち切られたことが何度かあります。後述する「落とし穴」の章で具体例とともに触れます。
必要な環境準備
まず Google AI Studio で API キーを取得し、環境変数に格納します。私はローカル開発では direnv を使って .envrc に書き、本番では Cloudflare Workers のシークレットに登録する形にしています。コードベースに直接 API キーを書くのは事故のもとなので、GitHub の Secret Scanning がプレースホルダーを誤検知しない範囲で、必ず環境変数経由にしておきます。
export GEMINI_API_KEY = "YOUR_GEMINI_API_KEY"
Python ライブラリは公式 SDK を使います。Python 3.9 以上を推奨します。
pip install google-generativeai
仮想環境(venv あるいは uv)を切ると依存管理が楽になります。本番では Cloud Run や AWS Lambda のコンテナイメージにベイクしてしまうのが取り回しやすい構成です。私は最近、軽量な構成として Cloudflare Workers + Durable Objects に Python ではなく TypeScript 経由で接続する形も試しており、こちらは Cold Start がほぼゼロで、サポート問い合わせのように散発的なリクエストにとても合っていました。
システムプロンプトを三層構造で書く
最初にやりがちな失敗は、システムプロンプトを「ひとつの長い指示文」として書いてしまうことです。半年ほど運用したあとで「ここだけ直したい」という時に、どこに書いたか思い出せなくなります。私は次の三層に分けて書くようにしています。
第一層は「役割と責務」で、ボットの自己定義です。第二層は「応答のガイドライン」で、トーンや出力フォーマット。第三層は「エスカレーション条件」で、人間に渡すべきタイミングを明示します。この三層に分けると、後から「敬語をもう少しカジュアルに」「返金関連は問答無用で人に渡す」といった変更を、該当ブロックだけ書き換えれば済みます。
この構造は、私が個人開発で複数のアプリ向けに異なるサポートボットを並列運用するようになってから固まったものです。アプリごとに異なる要素は第一層と第三層に閉じ込められ、第二層の「丁寧な敬語で短く返す」というガイドラインは共通テンプレートとして使い回せます。半年運用してからプロンプトの一部を改修するときに、差分が見やすくなる効果は大きいです。
import google.generativeai as genai
import os
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
SYSTEM_PROMPT = """あなたは「TechShop」の親切なカスタマーサポート担当者です。
【役割と責務】
- 製品に関する質問に、丁寧かつ正確に回答する
- 問題解決の手順を、分かりやすい言葉で説明する
- 自力で解決できない場合は、担当者への転送を提案する
【応答のガイドライン】
- 常に敬語を使用し、押し付けがましくない口調を保つ
- 手順は番号付きの短いステップで提示する
- 推測で回答せず、不明な点は正直に伝える
- 一度に複数の情報を詰め込まず、段階的に説明する
【エスカレーション条件】
- 返金・返品に関する複雑な案件
- 技術的な不具合の詳細調査が必要な場合
- ユーザーが「人間のサポートを希望」と明示した場合
- クレームや法的問題が示唆される場合
エスカレーションが必要と判断した場合は、回答の末尾に「[ESCALATE]」というキーワードを追加してください。"""
書き方の細部についてより詳しく学びたい方は、Gemini 2.5 Pro システムインストラクション マスターガイド もあわせて参考にしてください。
基本のチャットボット実装(マルチターン会話)
start_chat() を使うと、会話履歴を SDK が自動的に保持してくれます。注意点は、サーバープロセスが落ちると履歴が消えること、そして長く話し続けるとトークンが膨らむことです。後者は後述するコスト最適化の章で対処方法を書きます。
履歴の永続化は、軽量な使い方であれば Redis や Cloudflare KV にセッション ID をキーにして JSON シリアライズで保存する形で十分です。私は当初メモリ内に保持する形で始めて、後から KV に移行しましたが、移行はリファクタリングというより置き換えに近く、最初から外部ストアを前提に設計しておくほうが結果的に楽でした。
import google.generativeai as genai
import os
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
class CustomerSupportBot :
def __init__ (self, system_prompt: str ):
self .model = genai.GenerativeModel(
model_name = "gemini-2.5-flash" ,
system_instruction = system_prompt,
)
self .chat = self .model.start_chat( history = [])
self .escalated = False
self .conversation_log = []
def send_message (self, user_message: str ) -> dict :
if self .escalated:
return {
"response_text" : "担当者への転送処理を進めています。少々お待ちください。" ,
"escalate" : True ,
}
try :
response = self .chat.send_message(user_message)
response_text = response.text
self .conversation_log.append({ "role" : "user" , "content" : user_message})
self .conversation_log.append({ "role" : "assistant" , "content" : response_text})
escalate = "[ESCALATE]" in response_text
if escalate:
response_text = response_text.replace( "[ESCALATE]" , "" ).strip()
self .escalated = True
return { "response_text" : response_text, "escalate" : escalate}
except Exception :
return {
"response_text" : "申し訳ございません。一時的なエラーが発生いたしました。少し時間をおいて再度お試しください。" ,
"escalate" : False ,
}
bot = CustomerSupportBot( SYSTEM_PROMPT )
for msg in [ "注文した商品がまだ届きません" , "注文番号は12345です" , "もう返金してほしいのですが" ]:
print ( f "ユーザー: { msg } " )
result = bot.send_message(msg)
print ( f "Bot: { result[ 'response_text' ] } " )
if result[ "escalate" ]:
print ( ">>> 担当者へのエスカレーションが必要です <<<" )
print ()
ここで意識したいのは、escalated フラグが立った後はモデルを呼ばずに固定文を返している点です。一度「人に渡す」と判断したセッションで、さらに会話を続けてしまうと、モデルが勝手に「やはり私の方で対応します」と前言撤回するケースがあります。これは実際に運用していて何度か遭遇したパターンでした。前言撤回が起きると、ユーザー側は「結局誰が対応しているのか」が分からなくなり、信頼を失う一番の要因になります。
Function Calling で FAQ を動的に引く
定型情報の取り違いを減らすには、モデルに直接答えさせるのではなく、FAQ データベースから引いた情報を読ませる形が確実です。Gemini API の Function Calling を使うと、モデル側が「この質問は FAQ を引いたほうが良さそうだ」と判断したときだけツールを呼び出してくれます。Function Calling の基本については Gemini Function Calling 入門 を参考にしてください。
FAQ データは、Google スプレッドシートで運用担当者が編集できる形にしておくと、エンジニアを介さずに更新できて便利です。私は SaaS 寄りの構成として、スプレッドシートを Cloud Functions 経由で 1 時間ごとに JSON 化し、それをアプリが起動時に読み込む形にしました。即時反映ではないものの、運用担当者が SQL を触らずに自走できる体制になります。
import google.generativeai as genai
import os
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
FAQ_DB = {
"配送" : {
"answer" : "通常 2〜5 営業日でお届けします。お急ぎの場合は速達オプション(+500 円)をご利用ください。" ,
"related" : "配送追跡は注文確認メールのリンクからご確認いただけます。" ,
},
"返品" : {
"answer" : "商品到着後 14 日以内であれば返品可能です。未開封・未使用のものに限ります。" ,
"related" : "返品用ラベルはマイページからダウンロードできます。" ,
},
"保証" : {
"answer" : "全製品に 1 年間のメーカー保証が付いています。" ,
"related" : "保証期間内の修理は無償で対応いたします。" ,
},
"支払い" : {
"answer" : "クレジットカード、銀行振込、コンビニ決済に対応しています。" ,
"related" : "分割払いはクレジットカードのご利用条件によって異なります。" ,
},
}
def search_faq (query: str ) -> str :
results = []
for keyword, info in FAQ_DB .items():
if keyword in query:
results.append( f " { info[ 'answer' ] } { info[ 'related' ] } " )
return " " .join(results) if results else "該当する情報が見つかりませんでした。担当者に確認の上、回答いたします。"
faq_tool = {
"function_declarations" : [
{
"name" : "search_faq" ,
"description" : "FAQ データベースを検索して、製品・サービスに関する情報を取得する" ,
"parameters" : {
"type" : "object" ,
"properties" : {
"query" : { "type" : "string" , "description" : "検索するキーワードや質問内容" },
},
"required" : [ "query" ],
},
}
]
}
model_with_faq = genai.GenerativeModel(
model_name = "gemini-2.5-flash" ,
system_instruction = SYSTEM_PROMPT ,
tools = [faq_tool],
)
chat_with_faq = model_with_faq.start_chat( history = [])
def handle_with_faq (user_message: str ) -> str :
response = chat_with_faq.send_message(user_message)
for part in response.candidates[ 0 ].content.parts:
if hasattr (part, "function_call" ) and part.function_call.name == "search_faq" :
query = part.function_call.args.get( "query" , user_message)
faq_result = search_faq(query)
response = chat_with_faq.send_message(
genai.protos.Content( parts = [
genai.protos.Part(
function_response = genai.protos.FunctionResponse(
name = "search_faq" ,
response = { "result" : faq_result},
)
)
])
)
return response.text
実装でつまずきがちなのは、search_faq が空文字や曖昧な結果を返したとき、モデルが「不明だ」と素直に言わずに、自分の知識で補完してしまうケースです。SYSTEM_PROMPT の冒頭に「ツールが結果を返さなかった場合は推測せず、不明である旨を伝える」と一文足すと、補完される確率が目に見えて下がりました。私の運用では、ツール呼び出しの結果が空だった場合は必ず人間にエスカレーションする、というルールも併用しています。
エスカレーション機能を品質保証の最後の砦にする
AI が「分からない」と素直に言える設計にしておかないと、ユーザーは答えのない答えを延々と読まされることになります。エスカレーション機能はコスト削減のためではなく、品質保証の最後の砦として作るのが正しい捉え方です。
import google.generativeai as genai
import os
from datetime import datetime
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
def summarize_conversation (conversation_log: list ) -> str :
if not conversation_log:
return "会話履歴なし"
model = genai.GenerativeModel( "gemini-2.5-flash" )
history_text = " \n " .join( f " { m[ 'role' ] } : { m[ 'content' ] } " for m in conversation_log)
prompt = f """以下の会話履歴を 3 文以内で要約してください。
問題の概要・現状・ユーザーの感情状態を簡潔に記載してください。
会話履歴:
{ history_text } """
response = model.generate_content(prompt)
return response.text
def assess_priority (summary: str ) -> str :
high_priority = [ "緊急" , "至急" , "返金" , "法的" , "クレーム" , "詐欺" , "被害" ]
return "高" if any (k in summary for k in high_priority) else "通常"
def create_escalation_ticket (conversation_log: list , user_id: str = "anonymous" ) -> dict :
summary = summarize_conversation(conversation_log)
priority = assess_priority(summary)
ticket = {
"ticket_id" : f "ESC- { datetime.now().strftime( '%Y%m %d %H%M%S' ) } " ,
"created_at" : datetime.now().isoformat(),
"user_id" : user_id,
"summary" : summary,
"priority" : priority,
"conversation_log" : conversation_log,
"status" : "open" ,
"assigned_to" : None ,
}
print ( f "[エスカレーション] { ticket[ 'ticket_id' ] } 優先度= { ticket[ 'priority' ] } " )
return ticket
私が個人開発で運用していたとき、ここに Slack 通知を組み合わせていました。優先度「高」のチケットだけ私のスマートフォンに即時通知を飛ばし、それ以外は朝にまとめて確認する運用です。1 件あたり平均 3 分程度の確認時間で済むようになり、夜中の通知に振り回されることがなくなりました。優先度判定のロジックは最初は単純なキーワードマッチで十分で、運用を続けながら少しずつ表現の揺らぎを吸収していけば、過度な過剰反応も少なくできます。
公式ドキュメントには書かれていない、運用で気づいた7つの落とし穴
本番運用で半年以上回した結果、公式ガイドには触れられていない、いくつかの「想定外」がはっきり見えてきました。私が踏んだ順に並べておきます。
第一に、Function Calling の結果を受けたあとのレスポンスで、モデルが時折ツール結果を無視して自前の知識で答えてしまう問題があります。回避策は、SYSTEM_PROMPT に「ツール結果は唯一の事実情報であり、それ以外を根拠にしないこと」と明示することです。これで一気に挙動が安定しました。発生頻度は数十回に 1 回程度でも、本番では確実に表面化するため、最初から押さえておくべきポイントです。
第二に、長時間の会話でトークンが膨らみ、応答速度とコストが両方悪化する問題です。会話履歴を素直に保持し続けると、20 ターンを超えたあたりからレスポンスが目に見えて遅くなります。対策として、10 ターンごとに過去ログを Gemini Flash で要約し、要約済みテキストを履歴の冒頭に差し替える「圧縮ローテーション」を実装しました。トークン数を計測しながらしきい値で発火させると、応答時間がほぼ一定に保てます。
第三に、ユーザーが怒っているときの過剰丁寧問題です。Gemini はトーンを推定して合わせますが、相手が感情的になっているとき「お客様のお気持ちは大変よく分かります。誠に恐れ入りますが…」と何度も繰り返してしまい、かえって距離感が出ます。私はこれを「クッション過多」と呼んでいて、SYSTEM_PROMPT に「謝罪の枕詞は 1 ターンに 1 回まで」と明示する形で抑えました。意外と効きます。
第四に、ユーザーが画像を送ってきたときに、テキスト前提の System Prompt が崩れる問題です。「画像が添付された場合は、まず画像の内容を 1 行で要約し、その後に質問への回答を続ける」というブランチを Prompt 側に持たせると、画像と質問が結合した自然な応答になります。
第五に、エスカレーション後の会話続行問題です。[ESCALATE] を出したのに、その次のターンで「やはり私の方で続けて対応いたします」と前言撤回するパターンが、低頻度ですが必ず起きます。実装側で「フラグが立ったらモデル呼び出しを止める」のが最も確実な対策です。
第六に、API キーの権限分離問題です。本番用と開発用で同じキーを使い回していると、開発時のテスト呼び出しが本番の使用量に混ざってしまいます。Google AI Studio で複数キーを発行し、用途ごとに分けるだけで、ダッシュボードの精度が大きく改善します。
第七に、レスポンスが空文字で返ってくる問題です。安全性フィルタが反応した場合、response.text が空文字になることがあり、UI 側で「無言の応答」が表示される事故が起きます。response.candidates[0].finish_reason を必ず確認し、SAFETY や RECITATION の場合は固定文に差し替える設計にしておくと安心です。私はここを最初に踏んで、ユーザー側のチャット欄に「…」だけが残った状態を見て胃が痛くなったことがあります。
コスト最適化と運用監視
個人開発の規模感で運用するなら、Flash 系モデルを基本にして、難しい質問だけ Pro にフォールバックする二段構成が現実的です。私の運用では、まず Flash で回答を生成し、response.candidates[0].finish_reason や応答長を見て、要約された短い応答や安全性フィルタに引っかかった応答だけ Pro で再生成する形にしました。コストは Flash 中心の予算内に収まりつつ、難題への精度は Pro に近づきます。
監視は最低限、次の三つを取っておくとよいです。一日あたりのトークン消費量、エスカレーション率、応答平均トークン数。Cloudflare Workers のログをそのまま BigQuery に流し、簡単なダッシュボードを Looker Studio で組むのがいちばん安く済みます。グラフを毎朝ながめる習慣を作っておくと、突然のスパイクや、特定の曜日に集中するエスカレーション傾向に早く気づけます。
エラーハンドリングの詳細は Gemini API エラーハンドリングガイド に、コスト最適化のテクニックは Gemini API コスト最適化の実用ノート にまとめています。
この優先度判定や Slack 通知の組み合わせは、運用初期に「とにかく見落としを減らす」ことを最優先にして組んだ仕組みでした。半年ほどたつと、本当に緊急なケースとそうでないケースの判別精度が上がり、夜間の通知音で目が覚める頻度はほぼゼロになっています。仕組みが成熟していく過程そのものが、サポートを技術で支える手応えだと感じています。
多言語サポートを破綻させない設計
カスタマーサポートのチャットボットでよく相談されるのが「日本語と英語、どちらの言語にも答えてほしい」という要件です。Gemini は文脈から言語を推定して応答を切り替えてくれますが、これを素直に任せきりにすると、思わぬ事故が起きます。
最も多いのが、ユーザーが日本語で書いた質問の中に英語の固有名詞(製品名やエラーコード)が混ざっていたとき、応答全体が英語になってしまうパターンです。私はこれを避けるため、SYSTEM_PROMPT の応答ガイドラインに「ユーザーの最初の発言の主要言語に合わせ、以降のターンも同じ言語で応答する」と明示しました。
もう一つは、英語圏のユーザーが Google 翻訳経由で送ってきた、機械翻訳調の日本語を真に受けて返してしまう問題です。文末の助詞が不自然だったり主語が省略されていたりすると、Gemini はそれを「日本語ネイティブの一風変わった表現」と解釈してしまい、同じくらい不自然な日本語を返してきます。対策としては、応答生成の前に「ユーザーの発言が機械翻訳由来である可能性を見分けて、その場合は元の言語に切り替える」という前処理を入れています。
ボットの応答品質をテストで担保する
サポートボットを長く運用するうえで欠かせないのが、応答品質を継続的にテストする仕組みです。私はリリース前と週次レビューで、過去の問い合わせから抜粋した 50〜100 ケースのテストセットを用意し、現在のシステムプロンプトで応答が崩れていないかを確認しています。
TEST_CASES = [
{
"input" : "注文番号 12345 の商品がまだ届きません" ,
"expect_no_escalate" : True ,
"expect_keywords" : [ "配送" , "確認" ],
},
{
"input" : "もう返金してほしい、本当に頭にきました" ,
"expect_escalate" : True ,
},
{
"input" : "Hi, I haven't received my order yet" ,
"expect_no_escalate" : True ,
"expect_lang" : "en" ,
},
]
def run_tests (bot_factory):
results = []
for case in TEST_CASES :
bot = bot_factory()
out = bot.send_message(case[ "input" ])
passed = True
if case.get( "expect_escalate" ) and not out[ "escalate" ]:
passed = False
if case.get( "expect_no_escalate" ) and out[ "escalate" ]:
passed = False
for kw in case.get( "expect_keywords" , []):
if kw not in out[ "response_text" ]:
passed = False
results.append((case[ "input" ][: 30 ], passed))
return results
期待するキーワードや、エスカレーションすべきか否かを明示しておくと、SYSTEM_PROMPT を改修するたびに、過去のケースで品質が劣化していないかをワンコマンドで確認できます。私の運用では、テストを通った回でも、月に一度は人間が改めて応答を読み直し、ルール化されていない違和感がないかをチェックしています。
既存 CRM や問い合わせフォームとの統合パターン
社内に Zendesk や HubSpot のような CRM がすでにある場合、Gemini API のチャットボットをそこから差し替えるのではなく、フロントに置く形が現実的です。私が選ぶ構成は次のようなものです。
ユーザーの問い合わせをまず Gemini ボットが受け取り、Function Calling で CRM の API を呼んで過去の取引履歴を引き寄せます。FAQ で解決できれば即時応答、解決できなければエスカレーションのチケットを CRM 側に作成し、人間の担当者がそのまま続きを引き継ぐ流れです。重要なのは、Gemini 側で持っている会話履歴を、エスカレーション時にチケットの本文として渡し切ることです。担当者が「何が話されたか」を読み直す必要がなくなり、引き継ぎの摩擦が大きく減ります。
問い合わせフォーム経由の場合、リアルタイムチャットほどの応答性は求められないので、Gemini Flash の同期呼び出しではなく、Cloud Tasks や AWS SQS 経由の非同期処理にしてしまうのが安定します。応答までのリードタイムが多少伸びても、ピーク時の応答失敗が減り、ユーザー体験としてはむしろ良くなりました。
複数のチャネル(メール、Web チャット、アプリ内サポート)で同じボットを使うときは、入口ごとに channel メタデータを付け、SYSTEM_PROMPT に「メールチャネルでは挨拶を 1 行入れる」「アプリ内サポートでは短めの段落で返す」といったブランチを書き分けています。トーンの微妙な調整は、運用しながら週次でログを見て修正していくのが結局いちばん早いです。
段階的に育てるための運用フロー
完璧なボットを最初から作ろうとすると、たいていプロジェクトが止まります。私が実践しているのは、次の三段階で育てる方式です。
第一段階では、ボットの応答を必ず人間が承認してから送信する「人手レビューモード」で運用します。最初の 2 週間でログを 200 件ほど読むと、何を間違えやすいかが手触りで分かるようになります。
第二段階では、信頼度の高いカテゴリ(配送状況確認、定型 FAQ)だけ自動応答に昇格させ、それ以外は人手レビューを継続します。私の場合、ここで「画像添付の問い合わせ」を自動化に含めず手動側に残したのが結果的に正解でした。画像が絡む問い合わせは個別事情が大きく、まだ人間の判断が必要だと感じます。
第三段階では、自動応答の比率を毎週少しずつ上げつつ、エスカレーション率と顧客満足度(CSAT)を継続観察します。CSAT が下がったらどのカテゴリで下がったかを特定し、SYSTEM_PROMPT のガイドラインを書き直す、というループを回します。週次のレビュー時間を 30 分だけ確保しておくと、自然と改善のリズムが回り始めます。
ここまで丁寧にやれば、半年後にはサポート対応のリードタイムが目に見えて短くなり、空いた時間で新しい機能の開発に取り組めるようになります。私自身、2014 年からアプリ運営の片手間でサポートを回してきましたが、Gemini API を組み込んでから、ユーザーへの返信が遅れて気持ちが重くなる時間が減りました。
仕組みづくりに時間を投じるほど、結果として、ひとりの開発者として「作る」「届ける」「直す」のそれぞれに、ちゃんと時間を割けるバランスが取れるようになります。サポートに追われるのではなく、サポートを設計する立場に少しずつ移っていけると、個人開発の景色が変わります。同じ課題に取り組んでいる方の参考になれば嬉しいです。