kaigo-gap
by ossudesu-lab
README.md
# kaigo_mcp
日本の介護の需給データを、**AIエージェントが使える道具**として公開するMCPサーバー。
Claude Code や Claude Desktop から接続すると、「この市は特養が足りているのか」を
公開データに基づいて答えられるようになる。
> ステータス: **サーバー・エージェント・eval まで動作。**
> 列A(ローカル7B)・列B(ローカル9B)・列D(Claude Haiku 4.5)を計測済み。
## 何をするものか
MCP(Model Context Protocol)は、LLMに道具を持たせるための規格。このリポジトリが作るのは
**道具の側**で、LLMそのものは含まない。サーバー単体ではAPIを一切呼ばず、課金も発生しない。
```
[考える側] [このリポジトリ]
Claude Code / 自作エージェント ←stdio→ kaigo-gap サーバー
「どの道具を使うか」を判断 呼ばれたらデータを返す
```
## 道具
| 道具 | 用途 |
| --- | --- |
| `get_national_baseline` | 全国の基準値。個別の数字を評価する前提になる |
| `lookup_insurer` | 市区町村名・保険者名で需給を引く |
| `rank_insurers` | 特養の不足順(充足順)に並べる |
主指標は **要介護3以上の認定者100人あたりの特養定員数**(全国24.6人・中央値26.7人)。
定義と出典は [kaigo_gap_analysis](https://github.com/ossudesu-lab/kaigo_gap_analysis) 側にある。
## 集計をこちらで書いていない
データは案4(kaigo_gap_analysis)が `export_web.py` で書き出し、
[公開ダッシュボード](https://kaigo-gap-analysis.vercel.app) が配信しているのと
**同じ `insurers.json` をコピーして使う**。ここで割り算を書き直さない。
指標の定義を2か所に持つと、エージェントの答えとダッシュボードの数字が食い違ったとき、
どちらが正しいのか判定できなくなる。案4の `dataset.py` が「定義は1か所」という方針で
書かれているので、それを跨いで守っている。更新は `python scripts/sync_data.py`。
## 道具の設計で気をつけたこと
**数字だけ返しても判断できない。** 「12.4」を渡されてもLLMは高いか低いか分からないので、
全国順位と、道具の説明文に全国値を含めている。
**同名自治体を1件に決め打ちしない。** 「府中市」は東京都と広島県にある。
候補を両方返し、絞り込みは呼び出し側に委ねる。
**特養定員0を「全国最下位」と読ませない。** 該当が90保険者あり、全部が同率1位になる。
同率件数を必ず添え、さらに「小規模自治体では珍しくなく、住民は近隣自治体の施設を
利用していることが多い」という注記を付ける。これが無いと、
`rank_insurers` の結果から「新郷村は全国最悪の地域」という誤った結論が出る。
ドメイン知識を道具の応答に埋めておかないと、呼ぶ側のモデルを変えるたびに
同じ誤読が再発する。プロンプトではなく道具側に置くのはそのため。
## 使い方
```
pip install -r requirements.txt
python scripts/sync_data.py # 案4からデータを取り込む
python scripts/smoke_test.py # 通信の疎通確認
```
Claude Code から使うには、このディレクトリで起動すれば `.mcp.json` が読まれる。
他のクライアントに登録する場合の設定:
```json
{
"mcpServers": {
"kaigo-gap": {
"command": "python",
"args": ["-m", "kaigo_mcp"],
"cwd": "/path/to/kaigo_mcp"
}
}
}
```
Windows のパスは、区切りをバックスラッシュ2つで書くこと。
```json
"cwd": "C:\\projects\\kaigo_mcp"
```
1つだと JSON の不正なエスケープ(`\p`)になり、**設定ファイルごと読めなくなる**。
このリポジトリの `.mcp.json` は実際にそれで壊れていて、
Claude Code から一度も読み込めていなかった。
## エージェント
```
python -m kaigo_mcp.agent --list
python -m kaigo_mcp.agent "尼崎市は特養が足りてる?" --verbose
```
道具はMCPサーバー越しに呼ぶ(関数を直接importしない)。importで済ませると
MCPを通していないことになり、「MCPサーバーを作った」という主張が検証されないため。
### 比較する列
| 列 | モデル | 場所 | 設定 | 1問コスト | 状態 |
| --- | --- | --- | --- | --- | --- |
| A | qwen2.5:7b | メイン機(CPU) | — | 0円 | 計測済み |
| B | qwen3.5:9b | RTX 5050 8GB | `num_ctx` **4096**(ollama既定) | 0円 | 計測済み |
| B' | qwen3.5:9b | RTX 5050 8GB | `num_ctx` **8192** | 0円 | 計測済み |
| C | Qwen3.5-397B-A17B | DeepInfra | — | 約0.8円 | **保留**(登録にカードが要る可能性) |
| D | Claude Haiku 4.5 | Anthropic | — | 約1.4円(実測) | 計測済み |
各列の差が1つの要因だけになるように組んでいる。
| 比べる | 分かること |
| --- | --- |
| A → B | ハードとモデル世代の効果(`probe` で切り分け済み: 品質は世代、速さはハード) |
| **B → B'** | **設定だけの効果。モデルも重みも同じ** |
| B' → C | モデルサイズだけの効果(同じ Qwen3.5 系で揃えてある) |
| C → D | モデル系統の差 |
**B と B' を両方残しているのは、片方だけだと消える発見があるため。**
同じモデル・同じ重みで、`num_ctx` を変えただけで正答率が 83% と 100% に割れる。
8k だけを載せれば「ローカル9Bは優秀」に見え、4k だけを載せれば
「ローカルは実用にならない」に見える。どちらも本当ではない。
列Cは保留中。当初 Qwen3-235B-A22B を指していたが、世代が3.0で列Bと揃わないうえ、
提供も終了していた。**無料枠のカタログは入れ替わる**ので、使う前に
`/models` で実在を確かめること。NVIDIA無料枠(`C-alt-nvidia`)も試したが、
1往復に168秒かかる回があり eval には使えなかった。
**ループは1本しか書かない。** 片方だけSDKのツールランナー、片方だけ手書きにすると、
列間の差がモデルの差なのかループ実装の差なのか分離できなくなる。
バックエンドは履歴の変換だけを担当する。
### 実測(列A・列B)
各6回・`python scripts/probe_tool_calling.py` で計測。
列A→列B ではハードとモデル世代が同時に変わるので、
切り分け用に **7B を GPU 機でも走らせた**(真ん中の列)。
| | 列A qwen2.5:7b (CPU) | qwen2.5:7b (GPU) | 列B qwen3.5:9b (GPU) |
| --- | --- | --- | --- |
| 道具呼び出し成功率(既定温度) | **4/6 = 67%** | **5/6 = 83%** | **6/6 = 100%** |
| 道具呼び出し成功率(温度0) | 6/6 = 100% | 6/6 = 100% | 6/6 = 100% |
| 指示追従(温度0) | **0/6 = 0%** | **0/6 = 0%** | **6/6 = 100%** |
| 1回あたり(温まった後) | 7.6秒 | 0.4秒 | 3.2秒 |
| 初回(モデルロード込み) | 102.6秒 | 36.1秒 | 19.2秒 |
#### 温度0が要るのは 7B 固有だった
`qwen2.5:7b` は既定温度だと `<tool_call>` の**開始タグが壊れ**
(`olith` `pering` のような数文字が先頭に付く)、ollama のパーサが認識できず
呼び出しが本文テキストに漏れる。閉じタグ `</tool_call>` だけが残るのが目印。温度0で解消。
**`qwen3.5:9b` では既定温度でも 6/6。** この壊れ方はローカル実行一般の問題ではなく、
このモデルの世代・サイズに固有のものだった。
同じ 7B を GPU 機で走らせても既定温度で漏れ(5/6)、漏れ方の形まで同じだった
(`brtc {"name": "lookup_insurer", ...} </tool_call>` — 開始タグの位置に数文字が居座り、
閉じタグだけが残る)。**ハードを替えても直らない。** モデル側の癖である。
#### もっと大きい差は「指示に従うか」のほう
システムプロンプトは「答える前に `get_national_baseline` で全国の基準値を確認すること」
と指示している。**9B は 6/6 で従い、7B は 0/6 で一度も従わなかった。**
しかも 9B は2つの道具を1ステップで並列に呼ぶ。
7B が正しい答えを出せたのは、道具の説明文に埋めておいた全国値(24.6)を
拾ったからで、指示された手順は踏んでいない。**安定性ではなく指示追従の差**であり、
道具が増えるほど効いてくる。
#### 効いたのはモデル世代で、GPUは速度だけだった
7B を GPU に載せても、**品質の指標はどちらも動かなかった。**
既定温度でのタグ崩れは残り(4/6 → 5/6、n=6 なのでゆらぎの範囲)、
温度0での指示追従は 0/6 のまま。GPU 機の 7B は既定温度のとき6回中2回だけ
基準値を引いたが、温度0では一度も引かない。
動いたのは秒数だけで、**7.6秒 → 0.4秒(約19倍)。**
つまり A→B の差のうち、成功率と指示追従は**モデル世代の効果**、
速さは**ハードの効果**と読める。同じ表の中で分離できた。
ついでに、9B は 7B より**8倍遅い**(3.2秒 vs 0.4秒)。
同じ GPU でも、道具を2つ並列に呼び、指示に従うぶんだけ出力が長い。
**速さと指示追従はここで真正面からトレードオフになっている。**
#### 秒数についての前回の訂正の、さらに裏取り
列Bの「既定 18.9秒 → 温度0 4.4秒」は温度の効果ではなく初回ロードだった。
改良版スクリプトで採り直すと、**初回 19.2秒・以降 3.2秒**(温度0では以降 3.9秒)で、
温まった後は温度で差がない。列Aの**初回 102.6秒・以降 7.6秒**と同じ構図で、
初回を混ぜた平均は温度差にもハード差にも見えてしまう。
### 通しで走らせると、賢さより先にコンテキストが落ちた
道具呼び出し単体ではなく、エージェントを1問最後まで回した結果。
| 「尼崎市は特養が足りてる?」 | 列A 7B (CPU) | 列B 9B (4k) | 列B' 9B (8k) |
| --- | --- | --- | --- |
| ステップ | 2 | 2 | 3 |
| 秒 | 72.31 | 75〜89 | 21.6〜37.2 |
| 入力 / 出力トークン | 1,648 / 171 | 1,985 / **3,121** | 3,176 / 601 |
| 答え | 正しい | **空(0/4回)** | 正しい(3/3回) |
**先に頭打ちになったのは VRAM ではなく、`ollama` の既定 `num_ctx` = 4096 だった。**
9B が 6.6GB を占めるので KVキャッシュが苦しいと踏んでいたが、
そこへ届く前に既定のコンテキスト長で止まる。
止まり方が分かりにくい。2ステップ目が `stop_reason='length'` で、
プロンプト約1,150 + 出力2,946 = ちょうど 4,096。**出力は3,121トークンあるのに本文は空。**
引き金はモデル自身の誤った引数だった。尼崎市は兵庫県なのに
`lookup_insurer({'name': '尼崎市', 'pref': '大阪'})` を投げて該当0が返り、
そこから2,946トークン考え込んで、答えを書き始める前に使い切る。
**自己回復に要るトークンがコンテキストを食い潰す。**
`num_ctx` を 8192 にすると解消するが、今度は `ollama ps` が `12%/88% CPU/GPU` を出す。
8GB には載りきらない。**4k では答えが出ず、8k では GPU に載らない**、
というのが RTX 5050 8GB で 9B を回すということだった。
難しい質問(青森県で3市町村を挙げて全国と比較・`--max-steps 8`)は
4k でも 3ステップ・41.06秒で完走した。
**コンテキストを決めるのは質問の難しさではなく、回復の要否。**
#### 計測そのものの欠陥だった(修正済み)
直す前の `loop.py` は、道具呼び出しが無ければ `stopped_by = "end"` にしていた。
`length` で切られて本文が空でも "end" なので、
上の4回は記録上「2ステップで完走」と、列Aの成功と同じ形で残っていた。
**空答えが完走と同じ顔をしていたら、列を並べても意味がない。**
`length` または本文が空なら `truncated` を立てるようにした。
集計は `RunRecord.answered` を見る。CLI は理由を出して終了コード1を返す。
**この手の欠陥は、失敗する列を実際に踏むまで見つからない。**
列Aだけで回していたら気づけなかった。
そしてこの修正自体に穴が2つ残っていた。どちらも**列C・Dを回した瞬間に効く**。
- **切れたことを表す値が各社で違う。** OpenAI互換は `length`、Anthropic は `max_tokens`。
`length` しか見ていなかったので、**列D(Claude)では切れても検出できない**。
列Bで空答えを完走と数えていたのと同じ壊れ方が、そのまま列Dで再発する。
- **道具呼び出しの途中で切れる場合を見ていなかった。** 判定が
「道具呼び出しが無いとき」の中にあったため。引数のJSONが欠けたまま次へ進むと、
道具が失敗した記録だけが残り、原因が「モデルが下手」に見えてしまう。
`TRUNCATED_REASONS = {"length", "max_tokens"}` を道具呼び出しの有無より先に判定する。
`max_tokens` を 8(道具の手前で切れる)と 40(道具呼び出しの途中で切れる)に絞って
実際に踏み、正常系が `truncated` に化けないことも併せて確認した。
## eval の結果(列A・列B・列D)
6ケース・`python scripts/eval_agent.py` で計測。判定はすべてプログラムで書ける
条件のみ(LLM-as-judge は使わない)。列Bは3周(18件)。
| | 列A 7B (CPU) | 列B 9B (4k) | 列B' 9B (8k) | 列D Haiku 4.5 |
| --- | --- | --- | --- | --- |
| 正答 | 5/6 = 83% | 15/18 = 83% | **18/18 = 100%** | 15/18 = 83% |
| 完走 | 6/6 = 100% | 15/18 = 83% | 18/18 = 100% | 18/18 = 100% |
| **指示追従** | **2/6 = 33%** | **18/18 = 100%** | **18/18 = 100%** | **17/18 = 94%** |
| 1問の秒数(中央値) | 72〜85秒 | 16.9秒 | 24.0秒 | 6.5〜8.5秒 |
| コスト | 0円 | 0円 | 0円 | 25円 |
### `num_ctx` は速度の設定ではなく、正答率の設定だった
4k の失点3件はすべて `amagasaki-basic` の `truncated/length`。
仕組みは前節([通しで走らせると、賢さより先にコンテキストが落ちた](#通しで走らせると賢さより先にコンテキストが落ちた))
で見たものがそのまま eval にも出ただけで、新しい壊れ方ではない。
evalまで通して分かったのは、**同じモデル・同じ重みで、
設定だけで正答率が 83% と 100% に割れる**こと。
8k は回復ぶんの余地を与えているだけで、モデルは何も変わっていない。
### 「ローカルが Haiku を超えた」とは書けない
この6ケースでは列B'(8k)が上回っている(正答 18/18 対 15/18、
指示追従 18/18 対 17/18)。ただし**そう読むには弱すぎる**。
- **6ケース×3周しかない。** 母数が小さい
- **Haiku の失点は性能由来ではない。** 下の「効きすぎた注記」の通り、
道具側に埋めた注記を読んで定員0の4件を除外した回が含まれる。
こちらの設計に起因する失点を、モデルの弱さとして数えるのは誤り
- **秒数が3倍**(24.0秒 対 6.5〜8.5秒)。同じ土俵ではない
言えるのは「**この6問では、8kのローカル9Bで足りた**」まで。
0円で手元から出ないという条件を考えれば、それ自体が十分な結果ではある。
### 正答率が同じでも中身は違う(列A)
列A・列B・列D はどれも正答 83% だが、内訳が違う。
**列Aは道具の説明文に埋めた全国値を拾って偶然当てていて、
指示された基準値の確認をしていない**(指示追従 2/6)。
列B・列Dは毎回引く。道具が増えるほどこの差が効く。
列B の失点は上記の `truncated` で、当てられなかったのではなく
答えを書き終えられなかった。**同じ83%でも、届いていない理由が三者三様。**
正答率という1つの数字だけを並べると、この違いが消える。
### 道具側の注記は効いたが、効きすぎた
`rank_insurers` の応答に「定員0は小規模自治体では珍しくない、単独で断じるな」と
埋めてある。列Dはこれを読んで、**定員0の4件を答えから除外した**(3周中1回)。
数字は実データどおりででっち上げではない。
誤読は防げたが、代わりに情報が隠れた。**「断じるな」が「触れるな」と解釈された。**
ドメイン知識を道具の応答に埋める判断は効くが、効きすぎることがある。
## これから
- 列Bは 4k / 8k の**両方を載せる**と決めた(B と B')。設定だけで割れることが本題なので
- 列C は保留。DeepInfra の登録にカードが要る可能性があるため
- NVIDIA無料枠は eval には使えない(速度が保証されない。1往復168秒の回があった)
## 関連
### この計測を書いた記事
- [ローカルLLMは力不足だと思っていた。同じ重みのまま、設定ひとつで83%が100%になった](https://zenn.dev/ossudesu/articles/zenn-local-agent-numctx-article)
`num_ctx` が正答率の設定でもあったこと、空答えを「完走」と数えていた計測の欠陥を、
このリポジトリの実測をもとに書いている。
### 同じ作者の関連プロジェクト
- [kaigo_gap_analysis](https://github.com/ossudesu-lab/kaigo_gap_analysis) — 保険者別の需給ギャップ分析(**このデータの出所**)
- [kaigo_matching](https://github.com/ossudesu-lab/kaigo_matching) — 介護記録から8項目を構造化して取り出すパイプライン
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues