決定的なプログラムとは?AIに任せる前に知りたい「同じ入力なら同じ答え」
AIに任せた仕事を「たまたま動いた」で終わらせないために。プログラミングの「決定的(deterministic)」の意味、結果がブレる5つの原因、揺れるものを引数で外から渡すコツを短いTypeScript例で解説。temperature 0 でも揃わないAIの周りを、テストやhooksで決定的に固めます。
AIに任せた仕事を「たまたま動いた」で終わらせないために。プログラミングの「決定的(deterministic)」の意味、結果がブレる5つの原因、揺れるものを引数で外から渡すコツを短いTypeScript例で解説。temperature 0 でも揃わないAIの周りを、テストやhooksで決定的に固めます。
AIにコードを書いてもらっていると、同じお願いをしたのに昨日と今日で違うコードが返ってきたり、さっき通ったテストがなぜか落ちたりすることがありますよね。こうしたモヤモヤの正体を説明してくれるのが、プログラミングでよく使われる「決定的(deterministic)」という言葉です。この記事では、決定的の意味、結果がブレる原因、自分のコードを決定的にするコツ、そしてAIと一緒に開発するときの活かし方を、短いTypeScriptの例を交えて解説します。先に結論を言ってしまうと、AIの中身は決定的にできないので、その外側を決定的な部品で固めるのが一番の近道です。
決定的なプログラムとは、同じ入力を渡せば、いつ何度実行しても同じ答えを返すプログラムのことです。
一方で、AIの出力は確率で決まります。同じ質問でも答えが揺れるのは、AIの性質そのものです。ここを無理に揃えようとするより、AIが書いたコードを確かめる仕組みの方を決定的にしておく方がずっと確実です。
周りを決定的にしておくと、次のようなうれしいことがあります。
AIに任せた仕事が「たまたま動いた」から「毎回確かめられる」に変わる。これがこの記事で一番伝えたいことです。
身近な例でいうと、電卓は決定的です。「2 + 3」と打てば、今日でも10年後でも答えは5。自動販売機も、同じボタンを押せば同じジュースが出てきます。
反対に、サイコロや天気予報は非決定的です。同じように振っても出る目は違いますし、同じ空模様でも予報は日によって変わります。人に道を聞くのも同じで、聞く相手やタイミングによって教えてくれるルートが変わりますよね。
プログラムに置きかえると、答えを決める材料がすべて入力として渡されていて、それ以外のものに結果が左右されない状態を「決定的」と呼びます。この性質を持つ手順のことを、計算機科学では「決定的アルゴリズム」と呼びます。コードで見ると、違いは一目瞭然です。
const add = (left: number, right: number): number => left + right; const addWithNoise = (left: number, right: number): number => left + right + Math.random(); add(2, 3); // いつ実行しても 5 addWithNoise(2, 3); // 5.37…、5.81…と毎回変わる
add は渡された2つの数だけを見て答えを出します。addWithNoise は、引数とは関係なく Math.random() という「サイコロ」を中で振っているので、同じ入力でも答えが変わります。
ちなみに、add のように引数だけから答えを作り、外の世界に何も影響を与えない関数を純粋関数と呼びます。逆に、画面に表示する・ファイルに書き込むといった、関数の外の世界に影響を与える動きは副作用と呼ばれます。もうひとつ、現在の時刻や乱数のように、引数を通らずに関数の中へ入り込んでくる値もあります。この記事では、こちらを「外から入ってくるもの」と呼ぶことにします。純粋関数は、副作用もなく、外から入ってくるものも使わない関数というわけです。
非決定的になる原因は、ひと言でまとめられます。関数が、引数でもらっていない情報を自分から外に聞きに行くことです。聞くたびに答えが変わるので、関数の結果も変わってしまいます。よくある「質問」を5つ見てみましょう。
new Date() は、関数が時計に「いま何時?」と聞いている状態です。たとえば「今日がクーポンの有効期限内か」を判定する処理は、期限の日を過ぎた瞬間にテストが落ち始めます。日付をまたぐ夜中や、国ごとの時差がからむ場面も要注意です。
天気予報のAPIや為替レートのように、ほかのサービスに問い合わせる処理です。相手のデータが更新されれば、同じ問い合わせでも返事は変わります。そして、AIに質問する処理もまさにここに入ります。
プログラムのあちこちから触れるデータを読む処理です。グローバル変数(プログラムのどこからでも読み書きできる変数)や、前のテストがデータベースに残していったデータがその例です。読んだ瞬間の中身しだいで、答えが変わってしまいます。
2つのAPIを同時に呼んだとき、どちらが先に返ってくるかは通信の混み具合で毎回違います。先に返ってきた方を使うような処理は、実行するたびに結果が入れ替わりえます。
Math.random() のような乱数です。おみくじやランダムな並べ替えには欠かせませんが、答えを揺らすことがそもそもの目的なので、テストの中に入り込むと結果が安定しません。
こうして見ると、5つとも引数の外から入ってくるものです。関数が外に質問せず、引数だけを見ている限り、結果はブレません。
決定的なコードのテストは、何度実行しても同じ結果になります。逆に、たまにしか落ちないテストは厄介です。「また落ちたけど、どうせたまたまでしょ」と無視されるようになり、本当に壊れたときにも気づけなくなってしまいます。テストは毎回同じ判定をしてくれるからこそ信用できる道具です。
これはAIに開発を任せるときにも効いてきます。たまに落ちるテストがあると、AIは「コードが壊れた」と受け取り、本当は問題のない部分を書き換えたり、テストの条件をゆるめて通そうとしたりしかねません。テストが決定的なら、落ちたときは本当に何かが壊れている、とAIも人間も安心して判断できます。
不具合を直すときに一番困るのは、「どうやったら起きるのか分からない」状態です。決定的なコードなら、同じ入力を渡すだけで同じ間違いをもう一度起こせます。起こせれば、入力を少しずつ変えながら原因を絞り込めますし、直したあとで本当に直ったかも確かめられます。AIに修正を頼むときも、「この入力でこうなる」と渡せれば、AIは推測ではなく事実から原因を探せます。
決定的な関数は、引数を見れば答えが予想できます。これは人間にとっても、AIにとってもありがたい性質です。AIにコードを直してもらうときも、外から入ってくるものが少ない関数ほど、見当違いの修正をされにくくなります。関数の中で時刻やデータベースを読んでいると、AIはその関数の外側まで調べないと正しく直せません。引数だけで完結していれば、見るべき範囲がその関数の中に収まります。
自分のコードを決定的にするコツはシンプルで、揺れるものを関数の中で取りに行かず、引数として外から渡すことです。
営業時間内かどうかを判定する関数で考えてみます。
// 変更前:関数の中で「今」を取りに行く const isBusinessHours = (): boolean => { const hour = new Date().getHours(); return hour >= 9 && hour < 18; }; // 変更後:「今」を外から受け取る const isBusinessHoursAt = (now: Date): boolean => { const hour = now.getHours(); return hour >= 9 && hour < 18; }; isBusinessHoursAt(new Date('2026-01-05T10:00:00')); // いつ実行しても true
変更前は、テストを朝に動かすか夜に動かすかで結果が変わってしまいます。変更後は now を渡すだけで、夜中に実行しても「朝10時のとき」を確かめられます。変えたのは1行だけなのに、関数が決定的になりました。
料理にたとえると、レシピ(計算)はいつも同じにしておき、その日の食材(時刻や乱数)だけを外から渡すイメージです。もちろん、本番ではどこかで本物の「今」を取る必要があります。それは画面やAPIの入り口など、プログラムの端っこで isBusinessHoursAt(new Date()) と一度だけ書けば十分です。揺れるものは端っこに集め、真ん中は計算だけにしておくと、テストしやすい部分がどんどん増えていきます。
どうしても関数を書き換えられない場合は、テストの側で時刻を固定する方法もあります。Vitestのドキュメントでも、テストの一貫性を保つために日付をコントロールする方法が紹介されています。
import { afterEach, expect, it, vi } from 'vitest'; import { isBusinessHours } from './isBusinessHours'; afterEach(() => { vi.useRealTimers(); }); it('returns true at 10:00', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-05T10:00:00')); expect(isBusinessHours()).toBe(true); });
同じ考え方は、ほかの場面でも使えます。乱数はシード(種)を固定すれば毎回同じ並びにできますし、使っているライブラリのバージョンは package-lock.json のようなロックファイルで固定できます。npmの公式ドキュメントによると、ロックファイルがあれば、途中で依存ライブラリが更新されても同じ構成をインストールし直せます。
ここからはAIの話です。AIの出力の「ばらつき具合」は、temperature という設定で調整できると聞いたことがあるかもしれません。値を0に近づけるほど答えが安定する、という説明をよく見かけます。
ところが、0にしても完全には揃いません。Claude(Anthropic)の公式ドキュメントの用語集には、temperature を0にしても結果は完全には決定的にならず、同じ入力でもAPIを呼ぶたびに違う出力になりうる、とはっきり書かれています。さらにAPIリファレンスでは、新しいモデルでは temperature を1.0以外に設定できなくなったことも示されています。
なぜ揃わないのかを調べた研究もあります。Thinking Machines Labの記事では、あるモデルに temperature 0 で同じ質問を1,000回投げたところ、80通りの異なる答えが返ってきたそうです。主な原因は、サーバーの混み具合によって一度にまとめて処理するリクエストの数(バッチサイズ)が変わり、計算結果がわずかにずれることでした。
OpenAIにも同じ答えを返しやすくする seed という設定がありますが、公式のCookbookでは「できる限り(best effort)」という扱いです。
つまり、使う側がAIの中身を決定的にするのは、現実的にはかなり難しいということです。
これは開発の場面では、次のような意味を持ちます。
AIが悪いわけではなく、そういう性質の道具だということです。人間の同僚に同じ仕事を2回頼んでも、まったく同じコードは書かないのと似ていますよね。だからこそ、発想を切り替える必要があります。
AIの答えが揺れるなら、その答えを受け止める側を決定的にしておけばいいのです。牧場で動物を自由に歩かせつつ、周りの柵だけはしっかり固定しておくようなイメージです。
テスト、型チェック、リンター(書き方のルールを確かめる道具)は、同じコードに対して毎回同じ判定を返します。AIがどんなコードを書いてきても、同じ基準で合否を出してくれる審判です。
たとえば Claude Code の公式ドキュメントでは、AIが自分で実行できるチェック(テストやビルドなど)を渡すことが勧められています。チェックがないと、AIは「できたように見える」ところで作業を止めてしまい、人間がひとつずつ確かめる係になってしまう、という趣旨です。合否がはっきり出るチェックがあれば、AIは「書く → 確かめる → 直す」を自分で回せます。
AIエージェントには、プロジェクトのルールをまとめた指示ファイルを読ませることができます。ただし、ここに書いたことは、あくまで「お願い」です。同じ公式ドキュメントでは、指示ファイル(CLAUDE.md)は助言にとどまるのに対して、hooks(決まったタイミングで自動実行されるスクリプト)は決定的で、必ず実行されると説明されています。
「ファイルを編集したら必ずリンターを通す」のように例外なく守ってほしいルールは、AIの気分に任せず、自動で動く仕組みにしてしまう方が確実です。
なお、AIの出力の形を揃えたいなら、決められたデータ形式どおりに出力させる「構造化出力」という機能もありますが、揃うのは形式までで、中身が正しいかどうかは別問題です。
この考え方を製品の設計にまで落とし込んでいるのが、TypeSafe社の Jev というモデルです。Jev は文章を書く代わりに、「この問い合わせは返金の依頼か?」のような質問に対して、「はい」の確率を数値で返します。公式ドキュメントでも、処理の流れや決まったルールはコードに置き、AIには狭い判断だけを任せる設計が勧められています。
面白いのは、Jev自身も決定的ではないと公式が正直に書いている点です。検証記事では、同じ保険請求を15回評価したところ、ある質問の確率が0.43から0.53の間で揺れ、0.5の境目をまたいでしまいました。そこで、0.30〜0.70の「迷う帯」は人間の確認に回す、という方法が紹介されています。
type Decision = 'approve' | 'reject' | 'review'; // AIから受け取った確率を、毎回同じルールで振り分ける const decideByProbability = (probability: number): Decision => { if (probability > 0.7) { return 'approve'; } if (probability < 0.3) { return 'reject'; } return 'review'; // 迷う帯は人に回す }; decideByProbability(0.43); // 'review' decideByProbability(0.53); // 'review'
AIの答えが0.43と0.53の間で揺れても、振り分けの結果はどちらも「人に回す」で変わりません。AIの揺れを、決定的なコードが受け止めているわけです。ただし、公式も書いているとおり、これでAIが決定的になるわけでも、自動で決まった判定が正しいと証明されるわけでもありません。揺れを前提にしたうえで、どこで人間が確かめるかをコードで決めておく。これが現実的な付き合い方だと思います。
いきなり全部を変える必要はありません。まずは手元のコードで new Date() をひとつ見つけて、引数で受け取る形に書き換えてみてください。それだけで、AIに任せたコードを確かめられる範囲が少し広がるはずです。