AI活用

JavaScriptで送信前のトークン数を計算する|会話履歴を予算内へ収める実装

Node.jsから入力トークンを計数し、必須指示と最新質問を残して古い会話を一組ずつ削る実装。計数APIと概算の違い、出力予算、削れない入力や通信失敗の停止処理を解説します。

この記事の目次
  1. 計数API・ローカルtokenizer・生成後usageを使い分ける
  2. Node.jsから、送信する入力をそのまま数える
  3. 古い会話を一組ずつ外すfitTextChat
  4. 出力の余白を引いてから入力予算を渡す
  5. 日本語・英語・コードは、同じ条件で測り直す
  6. 上限境界と「削れない入力」をテストする

JavaScriptで送信前のトークン数を調べるなら、モデル・固定指示・会話履歴を含む、実際に送る入力を組み立ててから計数します。文字数を4で割った値や、本文だけを数えた値を、リクエスト全体の確定値として使わないようにします。

上限を超えたときも、文字列の途中を黙って切ると質問の意味が変わります。ここでは、必須指示と最新の質問を残し、削除可能な古い会話を質問と回答の一組ずつ外すNode.jsの実装を作ります。必須部分だけでも収まらなければ、送信を止めます。

計数API・ローカルtokenizer・生成後usageを使い分ける

方法 使う場面 取り違えないこと
送信先の計数API 入力を組み立てた後の確認 対象モデルと入力形式をそろえる。計数でも入力内容を外部へ送る
対象モデルに対応するローカルtokenizer 入力中の目安、候補を絞る前処理 本文のトークン化だけでは、メッセージやツール等の全体を再現しない場合がある
文字数による概算 極端に長い入力の早期検出 日本語・英語・コード共通の正確な変換式にはならない
生成後のusage 事前値との比較、実利用量の記録 送信前の上限検査には間に合わない

tiktoken系の実装を使うときも、対応するエンコーディングとモデルを確認します。OpenAI向けに得た数字を、そのままClaudeやOllamaのモデルへ流用しません。トークンの単位そのものはAIトークンの基本で整理できます。

OpenAI Responses APIには入力トークン計数用のエンドポイントがあります。ClaudeにもToken counting APIがあり、モデルと構造化した入力を渡します。ただしClaudeの公式案内では計数値は見積もりであり、生成時の実入力と少し異なる場合があるとされています。

Ollamaの生成APIにあるprompt_eval_countは、生成レスポンスで返る入力トークン数です。この値を、送る前に呼べる計数関数と混同しないでください。

スポンサーリンク

Node.jsから、送信する入力をそのまま数える

次をcount-openai.mjsとして保存します。Node.jsのサーバー側で動かす例で、追加SDKは使いません。モデルIDとAPIキーを設定した後、instructionsとinputを計数APIへ渡します。

// Node.jsのサーバー側専用。ブラウザへAPIキーを渡さない。
export function openAICounter({ model, apiKey, fetchImpl = fetch }) {
  if (!model || !apiKey) throw new Error('model and apiKey are required');
  return async ({ instructions, input }) => {
    const response = await fetchImpl('https://api.openai.com/v1/responses/input_tokens', {
      method: 'POST',
      headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ model, instructions, input }),
      signal: AbortSignal.timeout(15_000),
    });
    if (!response.ok) throw new Error(`TOKEN_COUNT_HTTP_${response.status}`);
    const data = await response.json();
    if (!Number.isSafeInteger(data.input_tokens) || data.input_tokens < 0) throw new Error('INVALID_COUNT');
    return data.input_tokens;
  };
}

HTTPエラー、JSONの形式異常、15秒のタイムアウトは呼び出し側へ返します。失敗したときに文字数の概算へ切り替えて「収まった」と判定しないためです。キーや入力本文をエラーログへ出さず、必要ならHTTPステータスと処理IDを記録します。

計数にもリクエスト回数と待ち時間が必要です。入力欄の1文字ごとにこのAPIを呼ばず、送信時や確認操作時にまとめます。計数対象の資料を送信してよいか、利用者の権限も先に確認します。

古い会話を一組ずつ外すfitTextChat

次はbudget.mjsです。turnsには、すでに応答が終わったテキストの質問・回答を古い順で入れます。残す必要がある決定事項は、信頼度と出所を保てる形で別途必須文脈へ整理し、削除可能と判断した往復だけをここへ渡します。

const text = (value, name) => {
  if (typeof value !== 'string' || !value.trim()) throw new TypeError(name);
  return value;
};
export async function fitTextChat({
  instructions, latest, turns, inputBudget, count, maxCountCalls = 20,
}) {
  text(instructions, 'instructions'); text(latest, 'latest');
  if (!Number.isSafeInteger(inputBudget) || inputBudget < 1) throw new RangeError('inputBudget');
  if (!Number.isSafeInteger(maxCountCalls) || maxCountCalls < 1) throw new RangeError('maxCountCalls');
  if (!Array.isArray(turns)) throw new TypeError('turns');
  const history = turns.map((turn) => {
    if (!turn || Object.keys(turn).sort().join(',') !== 'assistant,user') throw new TypeError('text turns only');
    return { user: text(turn.user, 'user'), assistant: text(turn.assistant, 'assistant') };
  });
  // 最初に削除を許可された履歴だけをturnsへ入れる。
  for (let dropped = 0; dropped <= history.length; dropped++) {
    if (dropped >= maxCountCalls) throw new Error('COUNT_CALL_LIMIT');
    const payload = {
      instructions,
      input: [
        ...history.slice(dropped).flatMap(t => [
          { role: 'user', content: t.user },
          { role: 'assistant', content: t.assistant },
        ]),
        { role: 'user', content: latest },
      ],
    };
    const tokens = await count(structuredClone(payload));
    if (!Number.isSafeInteger(tokens) || tokens < 0) throw new Error('INVALID_COUNT');
    if (tokens <= inputBudget) return { payload, inputTokens: tokens, droppedTurns: dropped };
  }
  throw new Error('REQUIRED_CONTEXT_TOO_LARGE');
}

毎回、削除後の入力全体を数え直します。各メッセージ単体の計数値を足す方法だと、リクエストの構造を含む値とずれる可能性があるためです。トークン数が予算以下なら入力と削除数を返し、必須部分も大きすぎればREQUIRED_CONTEXT_TOO_LARGEで止まります。

この関数は、文字列だけのuser/assistantの完結した往復に限定しています。ツール呼び出しと結果、画像・音声、思考ブロックをこの2項目へ押し込まないでください。それらを扱う場合は、依存するメッセージをひとまとまりに保つデータ構造と、対応する計数入力が必要です。

最新の質問が「さっきの二つ目を詳しく」のように過去の回答へ依存していれば、その回答を削除可能な履歴に入れること自体が不適切です。必要な往復を保護するか、利用者に対象を明示してもらいます。数字が収まることと、会話の意味が保たれることは別に確認します。

出力の余白を引いてから入力予算を渡す

同じフォルダへprepare.mjsを保存します。以下の16,000・2,000・500は計算を説明する値で、特定モデルの上限ではありません。利用モデルのコンテキスト上限と最大出力、推論を使う場合の出力枠の扱いを確認して置き換えます。

import { fitTextChat } from './budget.mjs';
import { openAICounter } from './count-openai.mjs';

const model = process.env.OPENAI_MODEL;
const count = openAICounter({ model, apiKey: process.env.OPENAI_API_KEY });
// 以下は計算例。使用モデルの実上限を確認して置き換える。
const contextLimit = 16_000;
const reservedOutput = 2_000;
const margin = 500;
const inputBudget = contextLimit - reservedOutput - margin;

const fitted = await fitTextChat({
  instructions: '日本語で回答してください。確認できないことは不明と書いてください。',
  latest: '静的な会社紹介サイトを更新します。公開前にリンクとスマートフォン表示を確認する手順を教えてください。',
  turns: [
    { user: '過去の雑談です。', assistant: '過去の回答です。' },
  ],
  inputBudget,
  count,
});
// 送信直前まで、計数した入力と同じオブジェクトを使う。
const request = { model, ...fitted.payload, max_output_tokens: reservedOutput, store: false };
console.log({ inputTokens: fitted.inputTokens, droppedTurns: fitted.droppedTurns });
// requestを既存のResponses API送信処理へ渡す。
// このファイルは計数のみを行い、回答の生成は実行しない。

この例の入力予算は13,500です。余裕として置く500も、どの入力に対しても十分だという保証はありません。実測との差や使う機能を見て決めます。出力枠を別に考える理由はコンテキストウィンドウの基本にもまとめています。

APIキーとモデルを環境変数へ設定したうえで、node prepare.mjsを実行します。計数した後でツール定義や長い指示を足すと、その時点で判定が古くなります。追加した場合は、追加分も含めて計数し直してください。conversationやprevious_response_idでサーバー側の会話を参照する構成も、この例の入力だけでは扱っていません。

日本語・英語・コードは、同じ条件で測り直す

次のコードは、prepare.mjsの末尾へ追加して使えます。固定指示とモデルをそろえ、各入力を計数します。結果はサンプル本文だけでなく、固定指示とメッセージ構造を含む値として記録します。

const samples = {
  ja: '更新前に差分を確認してください。',
  en: 'Check the diff before updating.',
  code: 'const total = items.reduce((n, item) => n + item.price, 0);',
};
for (const [kind, content] of Object.entries(samples)) {
  const inputTokens = await count({
    instructions: '入力を確認してください。',
    input: [{ role: 'user', content }],
  });
  console.log({ kind, model, inputTokens });
}
記録項目 残す内容
条件 日付、モデルID、計数方法、固定指示の版
入力 ja / en / codeの区分、入力データの版やハッシュ
事前値 計数APIから返ったinput_tokens。未実行なら未計測とする
生成後 同じ入力を生成へ渡した場合のusage、差分、終了状態

計数が上限内でも、認証やレート制限のエラーは起こり得ます。429の原因を入力長だけで決めず、OpenAI APIの429エラーの切り分けへ進めます。

上限境界と「削れない入力」をテストする

2026年9月12日に公式仕様を確認し、Node.js 24でローカル検証しました。計数関数とHTTP応答をテスト用の値へ差し替え、予算と同値なら通ること、古い往復だけを除くこと、最新質問・固定指示・元配列を変えないことを確認しています。必須部分の超過、計数回数上限、不正値、ツールを含む往復、空の質問、通信失敗、HTTP 401・429・500は止まります。

実APIへの計数・生成は未実施です。テストで用いた50や90は処理分岐を確かめる仮の値で、日本語や英語のトークン数の実測ではありません。導入時は送信可能なサンプルで事前値と生成後usageを記録し、モデルや入力形式を変えた際に測り直してください。

スポンサーリンク