テックブログ
医療AIが記録を作りきれなかったとき、どこまで復旧できるか
目次
訪問看護の記録を音声から作るとき、ときどき記録そのものができあがらないことがあります。文字起こしは成功しているのに、そのあとのAI処理が最後まで進まず、記録の欄がひとつも埋まらないまま終わる状態です。現場から見ると「録音したのに、もう一度書き直すしかない」という結果になります。
私たちはこの現象を検証環境で658回・29条件にわたって再現し、原因と打ち手を数字で確かめました。分かったことは2つあります。ひとつは、以前に入れた対策が問題を隣へ動かしていただけだったこと。もうひとつは、効くと思われている手当てのほとんどが効かなかったことです。
この記事では、測った条件と結果をすべて出したうえで、医療AIの記録を途中までの出力から復旧するときに何を基準にすべきかをご説明します。英語版は Recovering a clinical note when the AI stops short にあります。
先に、言葉の整理
以降で使う言葉をまとめます。ここだけ読んでも、後の実測の意味が分かるように書いています。
| 言葉 | どういうものか |
|---|---|
| トークン | モデルが読み書きする最小の単位。日本語ではおおむね1〜2文字ごとに切られる。入力も出力もこの単位で数える |
| 出力の上限 | 1回の生成で書き出せるトークン数の上限。上限に達すると、文の途中であってもそこで生成が終わる |
| 打ち切り | 上限に達して生成が終わった状態。モデルは異常とは言わず、終了の理由として「上限に達した」とだけ返す |
| 構造化出力 | 「この項目にはこの形式で書く」とあらかじめ決めた型に沿って書かせるやり方。記録のように欄が決まっているものに使う |
| 反復(暴走) | 同じ内容を言い換えながら書き続けて止まらなくなる状態。エラーにはならず、上限に達するまで続く |
| 温度 | 次の1語を選ぶときのばらつきの大きさ。小さいほど毎回同じような文になる |
| 反復ペナルティ | 一度使った語を選びにくくする設定。繰り返しを抑える目的で用意されている |
反復は「似ている」ではなく「使い回し」だった
まず、暴走している欄の中身を数えました。暴走した欄の文を句点で区切ると399文あり、内容の異なる文はそのうち10種類しかありませんでした。ユニーク率にして 2.5% です。
| 対象 | 文の数 | 内容の異なる文 | ユニーク率 |
|---|---|---|---|
| 暴走した欄 | 399 | 10 | 2.5% |
| 同じ記録の、直前まで書かれていた24欄 | — | — | 96.9〜100% |
同じ記録の中で、直前の欄までは正常に書けているという点が重要です。モデル全体が壊れているのではなく、特定の欄に入った瞬間に同じ言い回しの再生産が始まります。
実際に打ち切られた出力は、こういう形で終わっています(患者さんの情報を含まない合成例です)。
{
"schema_version": 1,
"observation": {
"general_condition": "落ち着いており、表情も穏やかであった。",
"sleep": "就寝時間は一定しており、夜間の中途覚醒についての訴えは聞かれなかった。睡眠に関する具体的な訴えは特になく、夜間の休息は取れている様子であった。就寝から起床までの睡眠については特段の問題は語られず、睡眠状態は安定していると考えられる。夜間の睡眠に関して困っている様子最後の行に注目してください。同じことを3回、言い方を変えて書いています。 そして4回目の途中で上限に達し、閉じ括弧も無いまま終わっています。この時点でケアと評価の欄はひとつも書かれていません。
入力の長さは、原因ではなかった
「長い録音だから書ききれない」という説明は自然に聞こえます。私たちも最初はそう考えました。しかし数字は違いました。
| 文字起こしの長さ | 暴走した回の出力トークン |
|---|---|
| 2,584字 | 5,983 |
| 6,140字 | 5,984 |
| 8,932字 | 5,985 |
| 11,599字 | 5,984 |
入力が4倍以上違っても、出力は同じところで頭打ちになります。出力量が入力量の関数になっていません。つまりこれは「情報が多すぎて書ききれない」現象ではなく、書く材料が尽きたあとも書き続けてしまう現象です。
ちなみに正常に完走したときの出力は中央値1,291トークン、95パーセンタイルでも1,510トークンでした。上限の6,000は正常時の約4倍の余裕があります。上限が足りないのではありません。
効かなかった打ち手を、先に並べます
同じ回り道をしていただかないために、測って外れたものを先に出します。すべて同じ音声・同じ条件で、10〜30回ずつ流した結果です。
| 試した手 | 結果 | なぜ効かなかったか |
|---|---|---|
| 出力の上限を引き上げる | 採用せず | 正常時の4倍の余裕がすでにある。上げると暴走が上限内で自力終了し、異常として検知されないまま保存される側へ回る |
| 構造化出力の型に最大文字数を指定 | 10回中8回が打ち切り | 型は形式の宣言であって、生成を止める機構ではない |
| 反復ペナルティを入れる | 10回中10回が打ち切り | 一部の値は API 側が受け付けず、エラーになった |
| 推論の深さの設定を変える | 低で83%、高で67% | 改善はするが解決しない |
| モデルをより新しい系統に変える | 10回中5回が打ち切り | しかも正常時の出力が2.7倍冗長になった |
| 「書かなくてよい」を許可する | 5% → 40% に悪化 | 書くか書かないかの判断が増え、迷う時間が長くなった |
| 欄の数を半分に減らす | それでも83%が打ち切り | 欄数は主因ではなかった |
| 温度を上げる | ほとんど変化なし | 下記 |
温度については、他の条件を固定して3水準で振りました。
| 出力のばらつき | 暴走した割合 |
|---|---|
| 小さい(0.1) | ほぼ変わらず |
| 中くらい(0.6) | ほぼ変わらず |
| 大きい(0.9) | ほぼ変わらず |
温度は、この種の暴走をほとんど左右しませんでした。 やり直しのときに温度を上げること自体は今も有効ですが、それは同じ確率の偏りにもう一度落ちるのを避けるための手当てであって、暴走を止める手段ではありません。
なぜ、書くことが無い欄で書き続けるのか
仕組みを具体的に見ておくと、最後に効いた対策がなぜ地味な一文なのかが分かります。
生成は1語ずつ前へ進む処理で、次の1語はそれまでに書いた全部を条件にした確率から選ばれます。ここで効いてくる性質が2つあります。
| 性質 | 材料の少ない欄で何が起きるか |
|---|---|
| 指示は「必ず書く」と言い、出力の型も空欄を許していない | 書き終えて次へ進むことが、モデルにとって安い選択肢にならない |
| 直前に書いた文章が、次の1語にとって最も強い文脈になる | いま書いた内容の言い換えが、常に最も確率の高い続きになる |
ひとことしか材料がない欄に入り、詳しく書けと言われ、空欄にもできない。そこで最も自然な続きは、いま書いたことの言い換えです。その言い換えがさらに次の文脈になります。これは異常な動作ではなく、こちらが書いた指示のもとで最も確率の高い道筋です。
出力の型に最大文字数を指定しても止まらないのは、このためです。型が制約するのはどのトークンが文法的に許されるかであって、どれが選ばれやすいかではありません。繰り返している間も、出力はずっと型の中にとどまっています。
欄ごとに塞ぐと、隣の欄へ移る
私たちは以前、家族に関する欄で同じ現象を見つけ、その欄だけを名指しして短く書くよう指示を足しました。家族の欄では確かに止まりました。
ところが今回あらためて測ると、暴走は隣の睡眠の欄へそっくり移っていました。
| 測った条件 | 家族の欄で暴走 | 睡眠の欄で暴走 |
|---|---|---|
| 名指しの対策を入れる前 | 全体の 59.7% | 全体の 39.5% |
| 家族の欄だけを名指しで厳しくした後 | 0 件 | 18回中18回 |
材料が少ない欄は家族だけではありません。健全に書けている記録で平均の字数を数えると、睡眠の欄は41字、家族の欄は106字でした。どちらも「会話にひとことしか出てこない」ことが普通の欄です。
「詳しく書け」という指示と「材料が少ない」という状況が重なる欄は、どれでも同じ挙動になります。 欄の名前で対策すると、その欄は直りますが、同じ条件を持つ次の欄が引き受けるだけです。
効いたのは、欄を名指ししない一文だった
最終的に効いたのは、すべての文字列の欄に共通する打ち切り規則をひとつ置くことでした。要点は3つです。
- 各欄は決められた字数・文数で必ず書き終える
- 会話に材料が少ない欄は、短いまま終えるのが正解だと明示する
- 言い換えての繰り返し・一般論での字数稼ぎ・その欄に書くべきか検討し続けることを、禁止として並べる
| 条件 | 同じ音声での暴走 |
|---|---|
| 対策前 | 10回中10回 |
| 共通の打ち切り規則を入れた後 | 100回中0回(95%信頼上限 3.0%) |
3つ目が効いている実感があります。暴走した出力を読むと、「この欄に書くべきか」を検討する文章そのものが記録の本文に流れ込んでいました。書く材料がないときにモデルが始めるのは、書くことではなく迷うことでした。
なお、同じ一文でも置き場所で効き方が変わります。同じ規則を指示文の先頭に置いた版を25回流すと8%が打ち切られ、末尾に置いた版(100回中0回)より悪い結果になりました。指示は足せば効くというものではなく、どこに置くかまで含めて測る必要があります。
それでも書ききれなかったときに、どこまで残すか
暴走を止めても、長い会話では上限に届くことがあります。記録が残らない日をなくすために、4段の受け皿を用意しています。
3段目が、ここで説明する途中までの出力から、完成している欄だけを取り出して保存する仕組みです。
最も重要な判断は、何をもって「完成した」とみなすかです。私たちは内容をいっさい見ず、文法だけで決めています。
// 値を読み終えたあと、次の構造の記号(, か })を
// 「実際に読み取れたとき」だけ、その欄を完成として採用する
skipWs(c);
if (c.i >= c.s.length || (c.s[c.i] !== ',' && c.s[c.i] !== '}')) {
// 区切りが見えない = 本当に書き終えたのか証明できない → 捨てる
if (!v.isContainer && c.truncatedAt === null) c.truncatedAt = childPath;
return { complete: false, value: obj, isContainer: true };
}
obj[key] = v.value; // ここで初めて採用が確定する判定の流れは、図にするとこれだけです。
内容で判断してはいけない理由があります。以前、繰り返しを検知して該当箇所を消す仕組みを入れたところ、患者さんが実際に繰り返し訴えた言葉まで重複として消してしまいました。臨床記録では、同じ言葉が続くこと自体が事実であることがあります。 だから採否は形式だけで決め、判断の余地を残さない設計にしています。
この規則は、実装では一度守れていませんでした。「区切りを読み取れたとき」と書いたつもりが、実際には「まだ文字が残っているか」しか見ておらず、"良眠"x のようなゴミが続く形でも採用していました。レビューで指摘され、採用の位置を区切りの確認より後ろへ動かしています。宣言と実装がずれていないかは、宣言のほうを読むだけでは分かりません。
復旧したあとの記録は、こういう形になります。書かれなかった欄は、空文字ではなく項目ごと存在しません。
{
"schema_version": 1,
"observation": { "sleep": "…", "mental": { "mood": "…" }, "risk": { … } },
"care": {
"conversation_summary": "…",
"interventions": []
// family_engagement と education は「無い」。空文字では入れない
},
"evaluation": { "plan_until_next": [] }
// assessment・doctor_report・next_visit_at も同様に無い
}空文字や定型文で埋めないのは、「観察して該当がなかった」と「AIがそこまで到達しなかった」を区別できなくするからです。項目ごと無ければ、画面では未記入として扱われ、あとから機械的に抽出することもできます。
一方で、参照するだけで画面が落ちる入れ物(配列や入れ子のオブジェクト)は補っています。値は補わず、器だけを用意する形です。とくに次回の訪問予定は、補うと「次回の予定なし」を記録したことになるため、絶対に補いません。
どこで切れたかで、残す価値が変わる
途中まで残せばよい、という単純な話でもありませんでした。どの欄まで到達してから切れたかで、残る情報の価値がまったく違います。
構造化出力では、型に並べた順序どおりに欄が書かれていきます。つまり打ち切りの位置は、型の並び順で決まります。
| 切れた位置 | 残る欄 | 保存するか |
|---|---|---|
| 会話の要約より後ろ(打ち切りの59.7%) | 会話の要約を含む大半 | 保存する |
| 会話の要約より手前(同 39.5%) | 29欄中 8欄 | 保存しない |
会話の要約は、その訪問で何が話されたかを一本にまとめた欄です。ここに到達する前に切れた記録を保存すると、ほとんどの欄が未記入の記録が残ります。読む人にとっては「観察した結果、該当がなかった」のか「AIがそこまで到達しなかった」のか区別がつきません。そのため、会話の要約を復旧できたときだけ保存すると決めました。
壊れたJSONを読むときに踏んだ、保存できない値
途中で切れた出力は、当然ながら壊れたJSONです。これを自前で読むときに、読めるが保存できない値を作ってしまう落とし穴がありました。
文字列のエスケープ \uXXXX は、4桁が16進であることを前提に読みます。ところが打ち切りは文字の途中でも起きるため、\uZZ のような壊れた並びが現れます。ここで桁の中身を検査せずに数値へ変換すると、変換に失敗した値が文字コード0のNULとして文字列に混ざります。
// 4桁が16進であることを確かめてから進める
const hex = c.s.slice(c.i + 2, c.i + 6);
if (!/^[0-9a-fA-F]{4}$/.test(hex)) return { complete: false, value: out, isContainer: false };
const cp = parseInt(hex, 16);
if (cp === 0) return { complete: false, value: out, isContainer: false }; // NUL は保存できない
out += String.fromCharCode(cp);なぜ致命的かというと、PostgreSQL の JSON 型は NUL を含む文字列を保存できないからです。実際に検証環境で確かめると、エラーコード 22P05(unsupported Unicode escape sequence)で保存そのものが失敗します。
つまり検査を入れていなければ、復旧に成功した回にかぎって保存が落ちるという、目的が完全に裏返る不具合になっていました。加えて、桁数を確かめずに6文字進めると閉じ引用符を飲み込んで次の欄の本文が混入し、その欄自体が消えることも再現しました。
もうひとつ、深い入れ子にも上限を置いています。再帰で読む実装は、深さ5,000で処理系のスタックを使い切ります。復旧処理は最後の砦なので、ここで例外を投げると残りのやり直しごと失われます。上限を超えたら「読み切れなかった」として静かに返す形にしました。
記録を残す仕組みが、記録を消しかけた
実装のレビューで、もうひとつ見つかりました。「書けた分を保存する」処理が、すでに完成していた記録を上書きしうる状態だったのです。
記録を作り直す操作では、それまでの上書き防止が意図的に外れます。そこへ「途中までの記録を保存する」処理が加わると、完成していた記録が数欄だけの記録に置き換わる経路ができていました。
対策として、保存の直前に既存の記録と比べています。比べ方は単純で、空でない文字列の数を数えるだけです。
| 既存の記録 | 復旧した記録 | 判断 |
|---|---|---|
| 0(まだ何も書かれていない) | 31 | 保存する |
| 37(完成している) | 32 | 保存しない。既存を残す |
これは今回に限った話ではありません。守りを足すと、守る対象を壊す経路が新しくできることがあります。 安全策を設計するときは、「保存先にすでに中身がある場合」を必ず数えるようにしています。
復旧した記録を、あとから見つけられるようにする
復旧した記録に目印の項目を足すことはしませんでした。目印は記録の型そのものに足すことになり、その型を読み込んでいる関数すべてを配り直す必要が出てきます。加えて、私たちが別に入れている出力の検査が、項目名のような文字列を異常として取り除いてしまうことも分かっていました。
代わりに、欠けていること自体を目印にしています。完成した記録には必ずあり、途中で切れた記録には無い欄が2つあるので、その2つが同時に欠けていることで抽出できます。
SELECT count(*) FROM nursing_records
WHERE NOT (record_data->'care' ? 'family_engagement')
AND NOT (record_data->'evaluation' ? 'assessment');本番に出す前に、既存の記録で誤って引っかからないかを確かめました。372件中0件だったので、今後ここに現れるものは本物の復旧記録だと判断できます。
なお、1つの欄の欠落だけで判定してはいけません。別のある欄は、古い指示のもとでは288件中77件で欠けていたのに、新しい指示では7件中0件になっていました。どの欄が欠けやすいかは指示によって変わります。 だから2つの欄の同時欠落で判定しています。
どう確かめたか
作った仕組みそのものが正しく働くかは、3つの方法で確かめています。
| 確かめ方 | 内容 | 結果 |
|---|---|---|
| 単体テスト | 壊れたJSONの各パターン(不正なエスケープ・途中の桁・重複キー・深い入れ子・コードフェンス付き) | 517件が通過 |
| 変異注入 | 入れた守りを1つずつ壊して、テストが赤くなることを実測する | 11本すべてで赤を確認 |
| 実機での再測定 | 本番と同じ経路で、同じ音声を繰り返し処理する | 対策後は打ち切り0件 |
2つ目が特に重要です。テストが通ることと、テストが意味を持つことは別です。守りを壊しても緑のままなら、そのテストは何も見ていません。今回も、最初に書いたテストのうち1本は壊しても赤くならず、書き直しています。
訪問看護の記録づくりでは、こうした「うまくいかない日」をどれだけ減らせるかが、そのまま現場の負担に直結します。MENTRAでは訪問看護記録の作成を音声から支援しており、今回の仕組みもそこに組み込んでいます。同じような記録の詰まりでお困りでしたら、お問い合わせからお気軽にご相談ください。
よくあるご質問
AIの記録が途中で終わってしまうのは、入力が長すぎるからですか
多くの場合、原因は入力ではなく出力の側にあります。私たちが測った範囲では、文字起こしが2,584字でも11,599字でも、暴走した回の出力は5,983〜5,985トークンとほぼ同じ長さで頭打ちになりました。入力量と出力量が対応していないので、短い録音でも起こりえます。疑うべきは「書く材料が少ない欄に、詳しく書くよう指示していないか」です。
出力の型に最大文字数を指定すれば防げませんか
私たちが測った範囲では守られませんでした。構造化出力の型に各欄の最大文字数を指定したうえで10回流したところ、8回が上限に達して打ち切られています。型で指定できるのは形式であって、生成の長さを機械的に止める仕組みではないと考えたほうが安全です。
途中まで書けた内容を残すのは危険ではありませんか
「どこまでを完成とみなすか」を内容で判断すると危険です。私たちは文法だけで決めています。値を読み終えたあとに区切り記号を実際に読み取れた欄だけを採用し、それ以外は最初から無かったものとして扱います。内容で判断すると、患者さんが実際に繰り返し訴えた言葉を「重複」として消してしまう危険があります。
復旧した記録は、通常の記録と見分けがつきますか
つきます。書ききれなかった欄は、空文字を入れるのではなく項目ごと存在しない状態で保存しています。画面では未記入として扱われ、後から機械的に抽出することもできます。定型文で埋めてしまうと「AIが到達しなかった」と「会話に出てこなかった」が区別できなくなるため、埋めない設計にしています。
