個人開発で続けているアプリのストア用メタデータを整えるため、夜間にローカルエージェントを回しています。Gemma 4 を Ollama で動かし、手元の機械だけで完結させる構成です。クラウドに出さず、費用もかからない。オフピークの時間帯に黙って働いてくれる、相棒のような存在でした。
ところが、ある朝ログを見て手が止まりました。
序盤の一手は数百ミリ秒で返っていたのに、後半になるほど一手あたりの待ち時間が伸びていきます。明け方には数秒待たされていました。処理した件数は同じ。モデルも同じ。変わったのは、会話に積み上がっていった文脈の長さだけでした。
無人で長く回す前提のエージェントほど、この「後半が重くなる」性質に足をすくわれます。今回はその正体を計測で突き止め、一手の遅延を平準化するまでの手順を残しておきます。
明け方の一手が、なぜ最初の一手より重いのか
ローカルLLMの一回の応答は、大きく二つの時間に分かれます。プロンプト全体を読み込んで内部状態を作る prompt-eval の時間と、そこから新しいトークンを一つずつ生成する generation の時間です。
generation の速度は、出力の長さでほぼ決まります。一方の prompt-eval は、渡したプロンプトの長さに比例して伸びます。会話履歴やツールの出力が積み上がるほど、毎回の prompt-eval が重くなっていきます。
無人エージェントは一手ごとに「これまでの全履歴+新しい指示」を投げ直します。つまり後半の一手は、前半の何倍もの文脈を毎回読み直しているわけです。出力の長さが同じでも、待ち時間だけが静かに膨らんでいく。これが明け方の数秒の正体でした。
応答時間の正体を、prompt-eval と generation に分ける
幸い、Ollama は応答ごとに所要時間の内訳を返してくれます。prompt_eval_count(読み込んだトークン数)、prompt_eval_duration(その所要時間)、eval_count(生成したトークン数)、eval_duration(生成の所要時間)の四つを見れば、遅延がどちらで起きているかを切り分けられます。
時間の単位はナノ秒です。ミリ秒へ直して扱います。
import time, requests
OLLAMA = "http://localhost:11434/api/chat"
def chat_once(model, messages, num_ctx=8192):
t0 = time.perf_counter()
r = requests.post(OLLAMA, json={
"model": model,
"messages": messages,
"stream": False,
"options": {"num_ctx": num_ctx, "temperature": 0.2},
}, timeout=600)
r.raise_for_status()
d = r.json()
wall_ms = (time.perf_counter() - t0) * 1000
prompt_tokens = d.get("prompt_eval_count", 0)
prompt_ms = d.get("prompt_eval_duration", 0) / 1e6 # ns -> ms
gen_tokens = d.get("eval_count", 0)
gen_ms = d.get("eval_duration", 0) / 1e6
return {
"text": d["message"]["content"],
"prompt_tokens": prompt_tokens,
"prompt_ms": round(prompt_ms, 1),
"gen_tokens": gen_tokens,
"gen_ms": round(gen_ms, 1),
"wall_ms": round(wall_ms, 1),
"tok_per_s": round(gen_tokens / (gen_ms / 1000), 1) if gen_ms else 0.0,
}
返り値に wall_ms(実測の往復時間)も入れておくと、計測の取りこぼし(モデルのロード時間など)に気づけます。
一手ごとに記録を残す
切り分けは、一回だけ測っても意味がありません。ループの各手で記録を残し、文脈長と prompt-eval の伸びを並べて眺めます。
import json, pathlib
LOG = pathlib.Path("agent_timing.jsonl")
def run_step(model, history, user_msg, step):
history.append({"role": "user", "content": user_msg})
m = chat_once(model, history)
history.append({"role": "assistant", "content": m["text"]})
record = {"step": step, **{k: m[k] for k in
("prompt_tokens", "prompt_ms", "gen_tokens", "gen_ms", "wall_ms")}}
with LOG.open("a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return m["text"]
このまま素朴に履歴を積み続けると、prompt_tokens が右肩上がりに増え、それに引きずられて prompt_ms も伸びる様子が、ログにそのまま現れます。
文脈長と prompt-eval の関係を実測する
手元の構成(Gemma 4・12B 量子化版、Apple Silicon のノート)で、文脈長を変えながら同じ生成量を投げた実測です。数値は環境で変わりますが、傾きの形は共通していました。
| プロンプトのトークン数 | prompt-eval P50 | prompt-eval P90 | 生成の所要時間(一定) |
| 約 2,000 | 0.31 秒 | 0.38 秒 | 約 1.1 秒 |
| 約 6,000 | 0.74 秒 | 0.92 秒 | 約 1.1 秒 |
| 約 12,000 | 1.02 秒 | 1.34 秒 | 約 1.1 秒 |
生成の所要時間は出力量で決まるので、ほぼ一定です。対して prompt-eval は、文脈が 2k から 12k へ伸びる間に約3倍へ膨らみました。後半の一手が重い理由は、この一列に尽きます。
ここで大事なのは、犯人が generation ではなく prompt-eval だと数値で確かめたことです。生成を速くしようとモデルを軽くしても、この問題は解けません。打ち手は「毎回読み直す文脈の長さ」を抑える側にあります。
num_ctx を既定任せにしない
Ollama の num_ctx(コンテキスト窓)には控えめな既定値が入っていることが多く、ここに最初の落とし穴があります。窓を超えた古いトークンは、エラーにならず黙って切り捨てられます。エージェントが「さっき決めたこと」を急に忘れたように振る舞うとき、この無言の切り捨てを疑ってください。
逆に、窓を必要以上に大きく取るのも損です。Ollama は窓のぶんだけ内部バッファを確保するため、メモリを圧迫し、空き文脈の読み込みにも時間を使います。極端に広げるとメモリ不足で落ちることもあります(この症状は Gemma 4 が Ollama でメモリ不足になるときの確認手順 に切り分けをまとめています)。
私自身は、実際の作業で積み上がる文脈の最大値を計測してから、そこに少し余裕を足した値で固定する運用にしています。毎回のオプション指定漏れを防ぐため、Modelfile に焼き込んでおくのが堅実です。
# 作業に合った窓を固定し、既定値による無言の切り捨てを避けます。
cat > Modelfile <<'MF'
FROM gemma4:12b
PARAMETER num_ctx 8192
PARAMETER temperature 0.2
MF
ollama create gemma4-agent -f Modelfile
窓を固定したら、次は「その窓の中に何を残すか」という設計に移ります。
作業文脈を有界に保つ — 直近N手とローリング要約
窓を広げても、文脈が無限に増えれば prompt-eval はまた伸びます。本質的な解は、エージェントが毎回読む文脈そのものを有界に保つことです。
私はこう組んでいます。直近のN手は逐語のまま残し、それより古い手は一段落の要約へ畳む。ツールの出力は長くなりがちなので、文字数で頭打ちにする。prompt_eval_count が軟上限を超えたら、古い手を要約へ移す。この三つを一つの器にまとめます。
class BoundedContext:
"""直近 keep_turns 手は逐語、それ以前は要約に畳む。
ツール出力は tool_cap 文字で頭打ちにして、文脈の膨張を抑えます。"""
def __init__(self, system, summarize, keep_turns=6, tool_cap=1200, soft_tokens=6000):
self.system = {"role": "system", "content": system}
self.summarize = summarize # 古い履歴を1段落へ畳む関数
self.keep_turns = keep_turns
self.tool_cap = tool_cap
self.soft_tokens = soft_tokens
self.summary = ""
self.turns = [] # [{role, content}, ...]
def add(self, role, content):
if role == "tool" and len(content) > self.tool_cap:
content = content[: self.tool_cap] + "\n…(以下省略)"
self.turns.append({"role": role, "content": content})
def fold_if_needed(self, last_prompt_tokens):
# prompt_eval_count が軟上限を超えたら、古い手を要約へ移す
if last_prompt_tokens <= self.soft_tokens or len(self.turns) <= self.keep_turns:
return
old, self.turns = self.turns[:-self.keep_turns], self.turns[-self.keep_turns:]
self.summary = self.summarize(self.summary, old)
def messages(self):
msgs = [self.system]
if self.summary:
msgs.append({"role": "system",
"content": "これまでの経緯の要約:\n" + self.summary})
msgs.extend(self.turns)
return msgs
要約を作る summarize 関数自体も一回の推論なので、毎手呼ぶと割に合いません。fold_if_needed のように軟上限を超えたときだけ畳むことで、要約のコストを数手に一度へ薄められます。
ツール出力の頭打ちは地味ですが効きます。一覧取得やログの全文がそのまま履歴に残ると、一手で数千トークンを食います。先頭だけ残して切るだけで、文脈の膨張を大きく抑えられました。
効果 — 一手の遅延を平準化する
同じ夜間バッチを、素朴な履歴積み上げ版と BoundedContext 版で流し比べた結果です。
| 計測点 | 素朴に積み上げ | BoundedContext |
| 序盤の一手(prompt-eval) | 0.31 秒 | 0.33 秒 |
| 終盤の一手(prompt-eval) | 1.02 秒 | 0.46 秒 |
| 一手あたりの中央値 | 0.71 秒 | 0.39 秒 |
| 120手バッチの総時間 | 約 21 分 | 約 12 分 |
序盤はほぼ互角です。差が出るのは終盤で、prompt-eval の中央値で約45%短縮できました。総時間でいえば、オフピークの窓に収まらず溢れていたバッチが、余裕をもって朝までに終わるようになりました。
数字以上に効いたのは、遅延が「予測できる」ようになったことです。一手の所要時間が後半でも一定なら、何手で何分かかるかを事前に見積もれます。無人運用では、この予測可能性そのものが安心材料になります。
本番運用で気をつけている点
要約に畳むと、当然ながら細部は失われます。古い手の正確な数値や識別子を後段で参照する設計だと、要約後に取り違える危険があります。私はこういう「後で正確に必要になる事実」は要約へ流さず、システムメッセージ側の小さな台帳に別管理しています。
もう一つは、num_ctx を絞りすぎる失敗です。窓を小さくすれば prompt-eval は軽くなりますが、直近N手すら収まらないと、エージェントが直前の指示を読み落とします。窓は「直近N手+要約+システム」が確実に収まる大きさを下限として決めてください。
ローカルLLMをエージェントの土台に据える全体構成は、Ollama でローカルLLMをAntigravityにつなぐ実装メモ にまとめています。あわせて読むと、計測から運用までの線がつながるはずです。
どこから始めるか
重いと感じているループがあるなら、まず次の三手だけ試してみてください。
chat_once を差し込んで、各手の prompt_tokens と prompt_ms を JSONL に残す
- その記録を眺めて、遅延が prompt-eval か generation かを切り分ける
- prompt-eval が犯人なら、
num_ctx を計測値で固定し、直近N手以外を要約へ畳む
計測なしに窓やモデルをいじると、たいてい遠回りになります。私自身、モデルを軽くする方向で何度か空振りしてから、ようやくこの一列にたどり着きました。同じ場所で足踏みしている方の、ひと晩ぶんの近道になれば嬉しいです。