前編では、Ollama で Gemma 4 をローカル起動し、対話で動かすところまでを扱いました。後編は、その手元のモデルを実際の作業に組み込む段に踏み込みます。対話ウィンドウに手で打ち込むだけでは、便利さは半分しか引き出せません。スクリプトから呼べるようにして初めて、繰り返す作業を任せられるようになります。
私自身、個人開発で、App Store や Google Play に出すアプリのストア文言やブログの下書きを大量に試す場面があり、その試行をローカルのGemma 4に肩代わりさせています。ここでは、API の叩き方、速度の詰め方、そしてクラウドへの安全な逃がし方を、実装を交えて整理します。
Ollama はローカルにREST APIを立てている
意外と知られていませんが、Ollama は起動すると裏でローカルにHTTPサーバーを立てています。既定では localhost:11434 で待ち受けていて、ここに投げれば対話ウィンドウを開かずにモデルを呼べます。まずは生成エンドポイントを叩いてみるのが分かりやすいです。
curl http://localhost:11434/api/generate -d '{
"model": "gemma4:e2b",
"prompt": "アプリのレビュー返信を3パターン、丁寧な敬体で出してください",
"stream": false
}'
stream を false にすると、生成が終わってから結果をまとめて受け取れます。スクリプトに組み込むときは、この一回呼び出しの形がいちばん扱いやすいです。逆に、長文を少しずつ表示したい用途では stream を true にして、届いた断片を順に処理します。
Python から呼んで繰り返し作業に組み込む
CLIから直接叩いてもよいのですが、繰り返し使うならスクリプトにまとめておくと楽です。標準ライブラリだけで書けるのも、依存を増やしたくない個人開発と相性が良い点でした。
import json, urllib.request
def ask_local (prompt: str , model: str = "gemma4:e2b" ) -> str :
payload = json.dumps({ "model" : model, "prompt" : prompt, "stream" : False }).encode()
req = urllib.request.Request(
"http://localhost:11434/api/generate" ,
data = payload, headers = { "Content-Type" : "application/json" },
)
with urllib.request.urlopen(req, timeout = 120 ) as res:
return json.loads(res.read())[ "response" ]
if __name__ == "__main__" :
print (ask_local( "このメモを箇条書き3点に要約してください: ..." ))
このくらいの薄いラッパーがあれば、定型の指示を関数として何度でも呼べます。私はストア説明文の言い回し出しや、メモの要約をこの形で回していて、手で打ち直す手間が一段消えました。timeout を必ず付けておくのが地味に大事で、モデルが詰まったときに処理ごと固まるのを防げます。
応答速度を体感で上げる
ローカルモデルでいちばん気になるのが、最初の一回の遅さです。モデルがメモリに読み込まれるまでの待ちが、初回だけ長く感じられます。ここは keep_alive の指定で大きく改善します。
curl http://localhost:11434/api/generate -d '{
"model": "gemma4:e2b",
"prompt": "ping",
"keep_alive": "30m"
}'
keep_alive を長めに指定すると、モデルがメモリに常駐し続け、二回目以降の呼び出しが速くなります。連続で何度も投げる作業の前に、一度ウォームアップの呼び出しを入れておくのがお勧めです。それでも遅いと感じる場合は、より小さいサイズのモデルに落とすか、プロンプトを短く保つのが効きます。出力を短く制限したいときは options の num_predict で上限トークン数を抑えると、待ち時間が読みやすくなりました。
遅さの原因を切り分ける — Ollama が返す時間フィールド
stream を false にした応答には、生成テキストのほかに所要時間の内訳がナノ秒単位で同梱されています。ここを読まずに「なんとなく遅い」だけで対処を選ぶと、効かない打ち手に時間を使ってしまいます。
主に見るのは次のフィールドです。
フィールド 意味 遅いときの読み方
load_durationモデルをメモリに載せるのにかかった時間 ここが支配的なら keep_alive とウォームアップが効く
prompt_eval_count / prompt_eval_duration入力トークン数と、その処理にかかった時間 毎回同じ長い前置きを渡していないか疑う
eval_count / eval_duration生成したトークン数と、その生成時間 生成そのものが遅い。num_predict か小型モデルで削る
total_duration受け付けから完了までの合計 内訳の合計と大きくずれる場合はキュー待ちを疑う
この内訳を秒に直して並べるだけの小さな計測スクリプトを、手元に1本置いています。
import json, time, urllib.request
NS = 1_000_000_000
def measure (prompt: str , model: str = "gemma4:e2b" ) -> dict :
payload = json.dumps({ "model" : model, "prompt" : prompt, "stream" : False }).encode()
req = urllib.request.Request(
"http://localhost:11434/api/generate" ,
data = payload, headers = { "Content-Type" : "application/json" },
)
started = time.perf_counter()
with urllib.request.urlopen(req, timeout = 300 ) as res:
body = json.loads(res.read())
wall = time.perf_counter() - started
load = body.get( "load_duration" , 0 ) / NS
gen = body.get( "eval_duration" , 0 ) / NS
tokens = body.get( "eval_count" , 0 )
return {
"wall_sec" : round (wall, 2 ),
"load_sec" : round (load, 2 ),
"load_share" : round (load / wall, 2 ) if wall else 0.0 ,
"tokens" : tokens,
"tok_per_sec" : round (tokens / gen, 1 ) if gen else 0.0 ,
}
if __name__ == "__main__" :
for i in range ( 3 ):
print (i, measure( "この機能の利点を箇条書きで3点挙げてください" ))
同じプロンプトを3回続けて投げるのがコツです。1回目は load_share が大きく出て、2回目以降はほぼ0に落ちます。もし3回目でも load_sec が残るなら、keep_alive が効いていないか、別モデルの呼び出しに常駐枠を押し出されています。逆に load_sec はほぼ0なのに wall_sec が長いなら、原因はロードではなく生成側なので、keep_alive をいくら伸ばしても改善しません。
tok_per_sec は、モデルとサイズを比べるときの物差しになります。私は使う候補のモデルについて、この3回ループを一度だけ回して load_sec と tok_per_sec を手元のメモに残しました。以後「この作業はどのモデルに任せるか」を勘ではなく、その表を見て決めています。測るのは一度で済み、判断は何度も使えます。
ローカルとクラウドを自動で切り替える
ローカルのGemma 4は軽い用途には十分ですが、込み入った指示や最新情報が要る作業では力不足を感じることがあります。そこで、ローカルでまず試し、失敗や品質不足のときだけクラウドのGemini APIへ逃がす二段構えにしておくと、費用を抑えつつ取りこぼしを防げます。
def ask (prompt: str ) -> str :
try :
out = ask_local(prompt)
if out and len (out.strip()) > 20 : # 明らかに空・短すぎる応答は不採用
return out
except Exception :
pass
# ローカルが不調・不十分ならクラウドへフォールバック
return ask_gemini_cloud(prompt) # 別途、Gemini API を呼ぶ実装
ここでの判断軸は、ローカルの応答が「使えるか」を簡単な条件で見て、駄目ならクラウドに上げる、という単純なものです。私の運用では、まず手元で雑に何案か出し、最終仕上げだけクラウドに任せる流れにしています。この切り替えを自動化しておくと、判断のたびに手を止めずに済みます。本番のスクリプトに組み込むなら、クラウド側の失敗も想定して、最終的に必ず何かを返す経路を用意しておくのが安全です。
ただし、採否の判定を「長さ」だけに預けると取りこぼします。軽量モデルは、上限トークンに達して文の途中で切れた出力を返すことがあり、これは長さの条件をすり抜けます。末尾の形も一緒に見ておくと精度が上がります。あわせて、どちらの経路を通ったかを1行だけ書き出しておくと、後から効き目を数えられます。
import json, time, pathlib
LOG = pathlib.Path( "ask_route.jsonl" )
def looks_usable (out: str ) -> bool :
s = (out or "" ).strip()
if len (s) < 20 :
return False
return not s.endswith(( "、" , "," , "," , "…" )) # 途中で切れた出力を弾く
def ask (prompt: str ) -> str :
route = "local"
try :
out = ask_local(prompt)
if not looks_usable(out):
route, out = "cloud" , ask_gemini_cloud(prompt)
except Exception :
route, out = "cloud" , ask_gemini_cloud(prompt)
with LOG .open( "a" , encoding = "utf-8" ) as f:
f.write(json.dumps({ "ts" : time.time(), "route" : route, "chars" : len (out)}) + " \n " )
return out
この1行があるだけで、「ローカルで何割さばけているか」を後から数えられます。採用率が下がってきたら、プロンプトの書き方かモデル選択のどちらかを見直す合図です。逆に採用率が高いのに手直しが増えているなら、判定条件が甘すぎるということになります。数字が出るようになって初めて、二段構えは調整できる仕組みになりました。
つまずきやすい3つの落とし穴
実際に組み込む中で、私がハマった注意点を3つ挙げておきます。
初回ダウンロードの待ちを失敗と勘違いする
モデルの取得は回線次第で数分かかります。進捗が伸びている間は正常なので、success の表示まで待つのが確実です。スクリプトから初めて呼ぶときは、未取得モデルだと最初の一回が極端に長くなる点に注意してください。事前に ollama run で一度落としておくのが回避策です。
メモリ不足で応答が極端に遅くなる
上位サイズのモデルを積みすぎると、メモリが足りずスワップが起きて、応答が一気に重くなります。対処は、常駐させるモデルを絞ることと、用途に対して過剰なサイズを避けることです。小型版で足りる作業に大型を使わない、という割り切りが効きました。
出力の体裁が安定しない
軽量モデルは、指示した出力形式から外れることがあります。箇条書きやJSONで欲しいときは、プロンプトで形式を具体的に示し、受け取った側でも軽く検証するのを推奨します。整形を完全にモデル任せにせず、スクリプト側で最低限の後処理を入れておくと、後段が安定します。
どこまでローカルに任せるか
最後に、線引きの話です。手元のGemma 4に向くのは、試行回数を増やしたい下書き、外に出したくないメモの相談、ネットが不安定な場所での軽作業です。逆に、最新情報の反映や長大なコンテキストを要する作業は、無理にローカルで粘らずクラウドへ上げた方が結果が安定します。
私の場合、この住み分けを自動化してから、APIコストを抑えつつ試行の手数を増やせるようになりました。手元で気兼ねなく案を量産し、ここぞという仕上げだけクラウドに託す。個人開発で道具を長く使ううえでは、この二段構えがいちばん現実的だと感じています。まずは前編のスクリプトに、今日紹介した一回呼び出しのラッパーを1つ足すところから始めてみてください。