0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェントを作ると、最初はだいたいこうなります。

const model = "some-big-model";

全部を強いモデルに投げる。設計としては簡単です。動けばうれしい。ただ、エージェントにするとこの雑さがそのまま請求額と待ち時間に出ます。

チャットなら1回の応答で終わる処理でも、エージェントは違います。計画を立てる、検索する、ツールを呼ぶ、結果を読む、足りなければやり直す。1つのユーザー要求の裏で、モデル呼び出しが何度も連鎖します。

ai&の公式ページでは、ai& Inferenceを「ひとつのAPI、国内完結」「低コスト、低レイテンシー」「OpenAIとAnthropicのSDKに互換」と説明しています。公式ブログでも、エージェント時代は推論の速さ、量、単価が本番投入の制約になりやすい、という話がされています。

そこでこの記事では、ai& Inferenceを「OpenAI互換APIとして差し替える」だけで終わらせません。モデルをタスクごとに分ける小さなルーターを作り、エージェントを運用するためのログまで取ります。

狙いはこの3つです。

  • 難しい判断は強めのモデルに渡す
  • JSON整形や分類は安いモデルに渡す
  • 1リクエストごとのコストと遅延をログに残す

派手なデモより、本番に近い地味な設計です。でも企業でAIエージェントを入れるとき、たぶん一番効くのはここです。「動いた」ではなく、「あとから改善できる」状態にします。

この記事でやること、やらないこと

この記事で作るのは、社内問い合わせを処理するAIエージェントの最小構成です。検索、既存チケット確認、チケット下書き作成という3つのツールを持たせます。

やることは次のとおりです。

  • OpenAI SDKの接続先をai& Inferenceへ向ける
  • タスク別にモデルを選ぶルーターを作る
  • tool callingで検索と下書き作成を呼ぶ
  • X-Aiand-Metrics でコストと推論時間を取る
  • レート制限とストリーミング時の計測漏れを考える

やらないことも先に書きます。

  • 「このモデルが常に万能」とは言わない
  • 手元で測っていない速度や精度を盛らない
  • LLMに本番チケットを直接作らせない

モデル比較は楽しいです。ただ、エージェントの本番投入では、モデル単体の感想よりも「どの仕事をどのモデルに任せ、どこで人間が止めるか」のほうが効きます。

ai& Inferenceで今回使う性質

まず、この記事で使う公式仕様を整理します。

ai& Inferenceは、OpenAI互換とAnthropic互換のAPIを提供しています。公式docsのOverviewでは、open-weight LLMをai&のインフラからOpenAI/Anthropic互換APIで提供し、per-token credit billingで使えると説明されています。

OpenAI SDKを使う場合は、接続先を https://api.aiand.com/v1 に変え、APIキーをai&のものに変えます。

import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.aiand.com/v1",
  apiKey: process.env.AIAND_API_KEY,
});

公式ドキュメントでは、OpenAI移行時に変えるものは主にbase URLとAPIキーだと説明されています。chat.completions.create、streaming、tool calls、structured outputsはOpenAI形状のまま使える、とも書かれています。

Anthropic SDKを使っている場合も、ai&はAnthropic互換の /v1/messages を提供しています。ただし呼ばれるのはClaudeそのものではなく、ai&のCatalogから選ぶopen-weight modelです。ここを混同しないほうがいいです。

公式ページとdocsから、この記事で使う性質だけ抜き出すとこうです。

観点 この記事で使う意味
OpenAI互換 既存のOpenAI SDK実装を大きく変えずに試す
Anthropic互換 Claude向け実装資産も移行候補にできる
国内インフラ 国内完結や日本の情報保護要件を気にする用途で検討しやすい
open-weight model Qwen、DeepSeek、GLM、Kimi、gpt-ossなどを用途で選べる
tool calling エージェントから検索や社内APIを呼べる
structured outputs 分類や抽出をJSONで扱いやすくする
response headers コスト、通貨、推論時間、request idをログに残せる

モデル一覧は固定で思い込まず、公式のModel Catalogか GET /v1/models を見るのが安全です。docsには、組織ごとに見えるモデルが変わるため、実際の正は GET /v1/models と書かれています。

2026年7月7日に確認したModel Catalogでは、たとえば次のようなモデルが載っています。

用途 モデルID 公式Catalog上の特徴
無料で試作 qwen/qwen3.6-27b Free to prototype、tool calling対応
安価な実行役 deepseek-ai/deepseek-v4-flash 1M context、tool calling対応
汎用の推論役 openai/gpt-oss-120b reasoning、tool calling対応
長文や重めの推論 deepseek-ai/deepseek-v4-pro 1M context、tool calling対応
コード寄り moonshotai/kimi-k2.7-code vision、documentにも対応
長い文脈 zai-org/glm-5.2 1M context、tool calling対応

公式の料金ページでは、Qwen3.6-27Bの無料枠、DeepSeek V4 Flashやgpt-oss-120bの低単価、1M contextのモデルなどが示されています。ただし価格と提供モデルは変わる可能性があります。記事内の固定表を信じるより、実行時に client.models.list() で見るほうが事故りません。

import "dotenv/config";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.aiand.com/v1",
  apiKey: process.env.AIAND_API_KEY,
});

const models = await client.models.list();

for (const model of models.data) {
  console.log(model.id);
}

この「モデル一覧をAPIから取れる」ことが、ルーターを作るときに地味にありがたいです。モデルIDをコードに埋めっぱなしにせず、起動時に存在確認できます。

import type OpenAI from "openai";

export async function assertModelsAvailable(
  client: OpenAI,
  requiredModelIds: string[],
): Promise<void> {
  const models = await client.models.list();
  const available = new Set(models.data.map((model) => model.id));
  const missing = requiredModelIds.filter((modelId) => !available.has(modelId));

  if (missing.length > 0) {
    throw new Error(`Unavailable ai& models: ${missing.join(", ")}`);
  }
}

こうしておくと、Catalog更新や組織の権限差でモデルが見えないときに、実行中の問い合わせ処理で突然落ちる前に気づけます。

なぜモデルを分けるのか

エージェントの中には、性質の違う仕事が混ざります。

仕事 欲しいもの
計画 何を調べるか、どの順で進めるか 推論力
分類 問い合わせ種別、緊急度、担当部署 安さと安定したJSON
ツール実行 検索、DB参照、チケット作成 tool calling
要約 長いログから要点を作る 長い文脈
最終文面 人間に見せる回答 日本語の自然さ

全部を最大モデルに投げると、分類やJSON整形まで高くなります。逆に全部を安いモデルに寄せると、計画ミスや判断ミスが増えます。

ai&の公式ブログでも、複雑な「考える」タスクと、JSON整形や検索のような「作業する」タスクに異なるモデルを充てる考え方が紹介されています。

この記事では、この考え方をコードにします。

作るもの

題材は「社内問い合わせを処理するエージェント」です。

ユーザーが自然文で問い合わせると、エージェントが次の流れで処理します。

  1. 問い合わせを分類する
  2. 必要なら社内ドキュメント検索ツールを呼ぶ
  3. 必要なら既存チケット検索ツールを呼ぶ
  4. 回答案とチケット下書きを作る
  5. コストと遅延をログに残す

構成はこうです。

重要なのは、LLMにいきなり本番操作をさせないことです。この記事の create_ticket_draft はあくまで下書きです。作成、更新、送信のような副作用がある処理は、人間の確認を挟む設計にします。

セットアップ

この記事のコードは、Node.js 18以降を前提にしています。fetch をそのまま使うためです。古いNode.jsを使っている場合は、Node.jsを上げるか、undici などのfetch実装を入れてください。

npm init -y
npm install openai dotenv
npm install -D typescript tsx @types/node

package.json に実行コマンドを追加します。

{
  "scripts": {
    "dev": "tsx src/main.ts"
  }
}

.env を作ります。

AIAND_API_KEY=sk-your-aiand-api-key

APIキーはサーバー側だけで使います。ブラウザに直接置くのはやめます。公式ドキュメントにも、APIキーは作成時に一度しか表示されないため安全に保存する必要がある、と書かれています。

クライアントを作る

// src/aiand.ts
import "dotenv/config";
import OpenAI from "openai";

const apiKey = process.env.AIAND_API_KEY;

if (!apiKey) {
  throw new Error("AIAND_API_KEY is required");
}

export const aiand = new OpenAI({
  baseURL: "https://api.aiand.com/v1",
  apiKey,
});

OpenAI SDKを使っている既存コードなら、この baseURLapiKey を差し替えるだけで大半は動かせます。ただし、モデルIDはOpenAIやClaudeの名前ではありません。ai&のCatalogにあるモデルIDを指定します。

モデル選択を1か所に集める

まずはベタにモデルを割り当てます。あとで実測ログを見て入れ替えやすいよう、アプリ全体にモデルIDを散らしません。

// src/models.ts
export type TaskKind =
  | "classify"
  | "plan"
  | "tool_loop"
  | "summarize"
  | "final_answer";

export type ModelPolicy = {
  model: string;
  maxOutputTokens: number;
  temperature: number;
};

const POLICIES: Record<TaskKind, ModelPolicy> = {
  classify: {
    model: "qwen/qwen3.6-27b",
    maxOutputTokens: 400,
    temperature: 0,
  },
  plan: {
    model: "openai/gpt-oss-120b",
    maxOutputTokens: 1200,
    temperature: 0.2,
  },
  tool_loop: {
    model: "deepseek-ai/deepseek-v4-flash",
    maxOutputTokens: 1000,
    temperature: 0,
  },
  summarize: {
    model: "deepseek-ai/deepseek-v4-flash",
    maxOutputTokens: 1200,
    temperature: 0.1,
  },
  final_answer: {
    model: "openai/gpt-oss-120b",
    maxOutputTokens: 1400,
    temperature: 0.3,
  },
};

export function getModelPolicy(kind: TaskKind): ModelPolicy {
  return POLICIES[kind];
}

この割り当ては正解ではありません。むしろ、正解を決め打ちしないための出発点です。

qwen/qwen3.6-27b は公式Catalogで無料試作用として紹介されています。deepseek-ai/deepseek-v4-flash は安価なモデルとして紹介されています。openai/gpt-oss-120b はreasoningとtool callingに対応するモデルとして載っています。

モデルの得意不得意は、アプリのタスクで測らないとわかりません。なので、後で「分類はこのモデルで十分」「最終回答はこっちのほうがよい」と入れ替えられる形にします。

コストと遅延を取る

ai&は通常のOpenAI互換レスポンスを崩さず、追加情報をHTTPヘッダーで返す設計です。コストや推論時間を受け取りたいときは、X-Aiand-Metrics: true を付けます。

OpenAI SDKだけで本文を取るのは簡単ですが、この記事ではヘッダーも読みたいので、計測用の小さな fetch 関数を用意します。

// src/chat.ts
import "dotenv/config";
import type { TaskKind } from "./models";
import { getModelPolicy } from "./models";

export type ChatRole = "system" | "user" | "assistant" | "tool";

export type ChatMessage = {
  role: ChatRole;
  content: string;
  tool_call_id?: string;
};

export type ChatResult = {
  content: string;
  model: string;
  requestId: string | null;
  cost: number | null;
  currency: string | null;
  inferenceMs: number | null;
  usage: unknown;
};

type ChatCompletionResponse = {
  choices: Array<{
    message: {
      content?: string | null;
    };
  }>;
  usage?: unknown;
};

export async function chat(kind: TaskKind, messages: ChatMessage[]): Promise<ChatResult> {
  const apiKey = process.env.AIAND_API_KEY;

  if (!apiKey) {
    throw new Error("AIAND_API_KEY is required");
  }

  const policy = getModelPolicy(kind);

  const response = await fetch("https://api.aiand.com/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "X-Aiand-Metrics": "true",
    },
    body: JSON.stringify({
      model: policy.model,
      messages,
      temperature: policy.temperature,
      max_tokens: policy.maxOutputTokens,
    }),
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`ai& request failed: ${response.status} ${body}`);
  }

  const body = (await response.json()) as ChatCompletionResponse;
  const message = body.choices[0]?.message?.content ?? "";

  return {
    content: message,
    model: policy.model,
    requestId: response.headers.get("x-request-id"),
    cost: readNumberHeader(response.headers, "x-cost"),
    currency: response.headers.get("x-cost-currency"),
    inferenceMs: readNumberHeader(response.headers, "x-inference-ms"),
    usage: body.usage ?? null,
  };
}

function readNumberHeader(headers: Headers, name: string): number | null {
  const value = headers.get(name);
  if (value === null) {
    return null;
  }

  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : null;
}

これで、レスポンス本文だけでなく X-Cost, X-Cost-Currency, X-Inference-Ms を取れます。公式docsによると、これらのコストヘッダーは非ストリーミング応答で返ります。ストリーミングの場合はヘッダーではなく、最後に event: metrics が追加される仕様です。

JSON分類は構造を固定する

分類は安いモデルに寄せたい処理です。ただし、戻り値が自由文になると後段が壊れます。

ここではJSONだけを返すように指示し、受け取った側でも検証します。ai& docsにはStructured Outputsの説明もあり、response_format によるJSON modeやJSON Schemaが紹介されています。この記事のサンプルでは最小構成にして、プロンプトとパースで扱います。

本番なら、ここはJSON Schemaを指定するほうが向いています。この記事であえてプロンプトとパースにしているのは、ルーターの構造を先に見せたいからです。分類の失敗が業務フローに響くなら、response_format とスキーマ検証を使います。

// src/classify.ts
import { chat } from "./chat";

export type InquiryCategory = "billing" | "security" | "bug" | "how_to" | "other";

export type InquiryClassification = {
  category: InquiryCategory;
  urgency: "low" | "normal" | "high";
  needsTicket: boolean;
  reason: string;
};

const CATEGORIES = new Set<InquiryCategory>([
  "billing",
  "security",
  "bug",
  "how_to",
  "other",
]);

export async function classifyInquiry(input: string): Promise<InquiryClassification> {
  const result = await chat("classify", [
    {
      role: "system",
      content: [
        "あなたは社内問い合わせの分類器です。",
        "JSONだけを返してください。",
        "categoryはbilling, security, bug, how_to, otherのいずれかです。",
        "urgencyはlow, normal, highのいずれかです。",
      ].join("\n"),
    },
    {
      role: "user",
      content: input,
    },
  ]);

  return parseClassification(result.content);
}

function parseClassification(content: string): InquiryClassification {
  const parsed = JSON.parse(extractJsonObject(content)) as Partial<InquiryClassification>;

  if (!parsed.category || !CATEGORIES.has(parsed.category)) {
    throw new Error(`Invalid category: ${String(parsed.category)}`);
  }

  if (!isUrgency(parsed.urgency)) {
    throw new Error(`Invalid urgency: ${String(parsed.urgency)}`);
  }

  if (typeof parsed.needsTicket !== "boolean") {
    throw new Error("needsTicket must be boolean");
  }

  if (typeof parsed.reason !== "string") {
    throw new Error("reason must be string");
  }

  return {
    category: parsed.category,
    urgency: parsed.urgency,
    needsTicket: parsed.needsTicket,
    reason: parsed.reason,
  };
}

function isUrgency(value: unknown): value is InquiryClassification["urgency"] {
  return value === "low" || value === "normal" || value === "high";
}

function extractJsonObject(content: string): string {
  const trimmed = content.trim();

  if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
    return trimmed;
  }

  const start = trimmed.indexOf("{");
  const end = trimmed.lastIndexOf("}");

  if (start === -1 || end === -1 || end <= start) {
    throw new Error("No JSON object found in classification response");
  }

  return trimmed.slice(start, end + 1);
}

LLMの出力を信用しすぎない。これだけで、エージェントはだいぶ扱いやすくなります。

ツールは小さく、結果もJSONにする

次にツールを用意します。本物の社内検索やチケットシステムにつなぐ前に、まずはモックで形を固定します。

// src/tools.ts
export type ToolName =
  | "search_internal_docs"
  | "list_recent_tickets"
  | "create_ticket_draft";

export type ToolCall = {
  name: ToolName;
  arguments: Record<string, unknown>;
};

export async function runTool(call: ToolCall): Promise<string> {
  switch (call.name) {
    case "search_internal_docs":
      return JSON.stringify(
        {
          hits: [
            {
              title: "SSOログイン障害時の一次対応",
              url: "https://example.internal/docs/sso-troubleshooting",
              excerpt: "IdP側の証明書更新、時刻同期、リダイレクトURLを確認する。",
            },
          ],
        },
        null,
        2,
      );

    case "list_recent_tickets":
      return JSON.stringify(
        {
          tickets: [
            {
              id: "SUP-1842",
              title: "一部ユーザーがSSOでログインできない",
              status: "closed",
            },
          ],
        },
        null,
        2,
      );

    case "create_ticket_draft":
      return JSON.stringify(
        {
          draftId: "draft-001",
          status: "needs_human_review",
          note: "これは下書きです。まだ外部システムへ登録していません。",
        },
        null,
        2,
      );
  }
}

本番では、ツールの中で権限チェック、監査ログ、レート制限を入れます。特に createupdate という名前の処理は、最初から本番反映にしないほうがいいです。

tool callingのループを書く

ai&はOpenAI Chat CompletionsとAnthropic Messagesの両方でtool callingをサポートしています。OpenAI形式では toolstool_choice を使います。

ここではOpenAI SDKで素直に書きます。

// src/agent.ts
import type OpenAI from "openai";
import { aiand } from "./aiand";
import { classifyInquiry } from "./classify";
import { getModelPolicy } from "./models";
import { runTool, type ToolName } from "./tools";

type Message = OpenAI.Chat.Completions.ChatCompletionMessageParam;
type AssistantMessage = OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam;

const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
  {
    type: "function",
    function: {
      name: "search_internal_docs",
      description: "Search internal support documents.",
      parameters: {
        type: "object",
        properties: {
          query: { type: "string" },
        },
        required: ["query"],
        additionalProperties: false,
      },
    },
  },
  {
    type: "function",
    function: {
      name: "list_recent_tickets",
      description: "List recent support tickets related to a query.",
      parameters: {
        type: "object",
        properties: {
          query: { type: "string" },
        },
        required: ["query"],
        additionalProperties: false,
      },
    },
  },
  {
    type: "function",
    function: {
      name: "create_ticket_draft",
      description: "Create a ticket draft for human review. This does not submit a real ticket.",
      parameters: {
        type: "object",
        properties: {
          title: { type: "string" },
          body: { type: "string" },
          urgency: { type: "string", enum: ["low", "normal", "high"] },
        },
        required: ["title", "body", "urgency"],
        additionalProperties: false,
      },
    },
  },
];

export async function answerInquiry(input: string): Promise<string> {
  const classification = await classifyInquiry(input);
  const policy = getModelPolicy("tool_loop");

  const messages: Message[] = [
    {
      role: "system",
      content: [
        "あなたは社内サポート担当のAIエージェントです。",
        "必要な場合だけツールを呼んでください。",
        "create_ticket_draftは下書き作成であり、実チケット登録ではありません。",
        "回答には、根拠にした情報と、人間が確認すべき点を含めてください。",
      ].join("\n"),
    },
    {
      role: "user",
      content: [
        `問い合わせ: ${input}`,
        `分類: ${classification.category}`,
        `緊急度: ${classification.urgency}`,
        `チケット下書き要否: ${classification.needsTicket}`,
        `分類理由: ${classification.reason}`,
      ].join("\n"),
    },
  ];

  for (let step = 0; step < 6; step += 1) {
    const response = await aiand.chat.completions.create({
      model: policy.model,
      messages,
      tools,
      tool_choice: "auto",
      temperature: policy.temperature,
      max_tokens: policy.maxOutputTokens,
    });

    const message = response.choices[0]?.message as AssistantMessage | undefined;

    if (!message) {
      throw new Error("No message returned from model");
    }

    messages.push(message);

    if (!message.tool_calls || message.tool_calls.length === 0) {
      return message.content ?? "";
    }

    for (const toolCall of message.tool_calls) {
      const name = parseToolName(toolCall.function.name);
      const args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
      const result = await runTool({ name, arguments: args });

      messages.push({
        role: "tool",
        tool_call_id: toolCall.id,
        content: result,
      });
    }
  }

  throw new Error("Agent exceeded max tool loop steps");
}

function parseToolName(name: string): ToolName {
  if (
    name === "search_internal_docs" ||
    name === "list_recent_tickets" ||
    name === "create_ticket_draft"
  ) {
    return name;
  }

  throw new Error(`Unknown tool: ${name}`);
}

ポイントは、ループ回数に上限を置いていることです。公式Cookbookの例にも、モデルがループし続けないように反復回数を制限する注記があります。

仕上げの回答だけ別モデルにする

ツールループのモデルにそのまま最終回答まで書かせてもいいのですが、今回はあえて分けます。

ツール呼び出しは「安く速く回す」。最後にユーザーへ見せる文章は「読みやすく、確認点が明確な形に整える」。この2つは別の仕事です。

// src/finalize.ts
import { chat } from "./chat";

export async function polishFinalAnswer(rawAnswer: string): Promise<string> {
  const result = await chat("final_answer", [
    {
      role: "system",
      content: [
        "あなたは社内サポートの回答文を整える編集者です。",
        "事実を増やさず、渡された内容だけを整理してください。",
        "ユーザーが次に確認すべきことを明確にしてください。",
      ].join("\n"),
    },
    {
      role: "user",
      content: rawAnswer,
    },
  ]);

  return result.content;
}

ここで大事なのは「事実を増やさない」と指示していることです。LLMは文章をきれいにできますが、勝手に根拠を足されると困ります。編集係として使うなら、編集係の権限に閉じ込めます。

動かす

// src/main.ts
import { answerInquiry } from "./agent";
import { polishFinalAnswer } from "./finalize";

const input = [
  "昨日から一部メンバーがSSOでログインできません。",
  "IdPの証明書を更新した直後から発生しています。",
  "同じ現象のチケットが過去にないか確認し、必要なら下書きを作ってください。",
].join("\n");

const rawAnswer = await answerInquiry(input);
const finalAnswer = await polishFinalAnswer(rawAnswer);

console.log(finalAnswer);

実行します。

npm run dev

期待する動きはこうです。

  • 問い合わせを security または bug 寄りに分類する
  • 社内ドキュメント検索を呼ぶ
  • 既存チケット検索を呼ぶ
  • 必要ならチケット下書きを作る
  • 最後に、人間が確認すべき点を含めた回答を返す

ここで「下書き」を作るだけにしているのが重要です。エージェントが間違える可能性を前提にすると、いきなり本番チケットを作るより、人間がレビューできる中間成果物にするほうが運用しやすいです。

コストログを残す

さきほどの chat() はコストと推論時間を返します。分類や最終回答で使ったコストをログに残せます。

たとえばこういう形です。

export type AiCallLog = {
  task: string;
  model: string;
  requestId: string | null;
  cost: number | null;
  currency: string | null;
  inferenceMs: number | null;
  createdAt: string;
};

export function printAiCallLog(log: AiCallLog): void {
  console.log(JSON.stringify(log));
}

本番なら、最低でも次を残します。

項目 理由
task 分類、計画、最終回答など、どこで使ったかを見る
model モデル入れ替えの判断に使う
cost ルーティングが効いているかを見る
currency 組織の請求通貨がUSDかJPYかを混同しない
inferenceMs 遅い箇所を見つける
requestId 問い合わせやサポート時に追えるようにする

ai& docsによると、X-Request-ID は各レスポンスに付与されます。障害調査や問い合わせに備えるなら、この値も保存しておくとよいです。

ストリーミング時の注意

チャットUIではストリーミングしたくなります。ただし、コストを見る場所が変わります。

公式docsでは、ストリーミング応答の場合、X-Aiand-Metrics: true を付けると、最後に event: metrics が追加されると説明されています。[DONE] の後に来るため、クライアントは接続が自然に閉じるまで読む必要があります。

つまり、ストリーミングで計測するならこういう方針になります。

通常のdeltaはUIに流す
[DONE]を受け取ってもすぐ切断しない
event: metricsを読んでcost, currency, ttft_ms, inference_msを保存する
接続が閉じたら完了扱いにする

ここを雑にすると、UIは動いているのにコストログだけ欠けます。エージェントの改善にはログが必要なので、最初から拾えるようにしておきたいところです。

レート制限もルーターで見る

エージェントは一度に複数のモデルを呼びます。モデルごとの制限だけでなく、組織全体の制限にも当たります。

ai& docsでは、レート制限は組織単位で、モデル別RPM、入力TPM、出力TPM、並列数、全体RPM、全体並列数の複数バケットで見られると説明されています。

なので、ルーターには「安いモデルを選ぶ」だけでなく「混雑時に落ち方を制御する」役割も持たせます。

export type RetryDecision =
  | { action: "retry"; delayMs: number }
  | { action: "fail" };

export function decideRetry(response: Response): RetryDecision {
  if (response.status !== 429) {
    return { action: "fail" };
  }

  const retryAfter = response.headers.get("retry-after");
  const seconds = retryAfter ? Number(retryAfter) : NaN;

  if (Number.isFinite(seconds) && seconds >= 0) {
    return { action: "retry", delayMs: seconds * 1000 };
  }

  return { action: "retry", delayMs: 1500 + Math.floor(Math.random() * 500) };
}

公式docsでは、429時は指数バックオフとジッターが推奨されています。上のコードは最小例なので、本番では試行回数の上限、タスクのキャンセル、ユーザーへの途中報告も入れます。

「安いモデルに逃がす」だけだと失敗する

ここは実装してみるとよくわかります。

安いモデルに寄せるだけなら簡単です。でも、それはルーティングではなく節約です。節約だけを目的にすると、失敗したリクエストが増えて、再試行や人間の手戻りで結局高くつきます。

僕なら、最初は次の基準で分けます。

タスク 最初に当てるモデル 失敗したら
問い合わせ分類 qwen/qwen3.6-27b JSON不正なら1回だけ同モデルで修復
短い要約 deepseek-ai/deepseek-v4-flash 抜けが多いなら上位モデル
ツールループ deepseek-ai/deepseek-v4-flash ツール選択ミスが多いなら openai/gpt-oss-120b
計画立案 openai/gpt-oss-120b 長文なら1M contextモデルも検討
コード修正案 moonshotai/kimi-k2.7-code 実プロジェクトのテストで判断

この表も固定ではありません。自分の業務データで変えます。

モデル選択で大事なのは、雰囲気ではなく失敗の種類を見ることです。

失敗 見るべきもの
JSONが壊れる Structured Outputsやスキーマを使う
ツールを呼びすぎる tool_choice、システムプロンプト、ループ上限
根拠のない回答が混ざる RAG結果以外を使わせない
遅い モデル変更、ストリーミング、並列化
高い タスク分割、要約、キャッシュされるprefix設計

ai&のPricing docsには、繰り返しのprompt prefixが自動検出され、対応モデルではキャッシュ入力単価で請求されると書かれています。安定したsystem promptやtoolsを使うエージェントでは、この性質も効きやすいはずです。

モデル比較で終わらせない

モデル比較の記事は読まれます。けれど、企業が本当に見たいのは「それをどう運用するのか」だと思います。

ai& Inferenceの強みとして公式が押し出しているのは、ざっくり言うとこのあたりです。

  • OpenAI、Anthropic互換
  • 国内インフラでの推論とデータ国内完結
  • open-weight modelの選択肢
  • エージェント時代の推論コスト
  • token単価、レイテンシ、throughputへの問題意識
  • tool calling、structured outputs、streamingなど既存アプリに必要な機能
  • request id、usage、cost、inference timeを使った運用ログ

なら、記事側もそこに合わせると強いです。

「1行差し替えて動きました」だけだと、読者はそこで終わります。この記事のように、差し替えたあとにモデルルーター、コストログ、レート制限、下書き運用まで書くと、導入後の絵が見えます。

企業の導入検討で聞かれやすいのは、だいたい次の問いです。

問い この記事の答え
既存のOpenAI実装を捨てる必要があるか SDK設定の差し替えから始める
どのモデルを選べばいいか タスク単位でルーティングし、後から入れ替える
コストが膨らんだらどう見るか X-Costusage をログに残す
遅い箇所をどう見つけるか X-Inference-Ms とストリーミングの metrics を見る
エージェントが勝手に操作しないか 本番操作ではなく下書きに閉じる
監査や問い合わせに耐えられるか X-Request-ID を保存する

モデルを触って終わりではなく、使い続けるための形に落とす。ai& Inferenceの互換APIとメトリクスは、そこにかなり相性がいいです。

ファクトチェック

この記事で公式情報として扱った点を、確認元と一緒に置いておきます。

記述 確認元
base URLは https://api.aiand.com、OpenAI SDKでは https://api.aiand.com/v1 を指定 Overview
OpenAI互換ではbase URLとAPIキーを変えて移行できる Migrating from OpenAI
Anthropic互換APIもある Migrating from Anthropic
モデル一覧は組織ごとに異なり、正は GET /v1/models Model Catalog
tool callingはOpenAI Chat CompletionsとAnthropic Messagesで対応 Tool Calling
Structured OutputsとしてJSON modeとStrict JSON Schemaがある Structured Outputs
X-Request-ID は全レスポンスに付き、X-Aiand-Metrics: true で非ストリーミング応答のコストヘッダーを受け取れる Response Headers
ストリーミング時は [DONE] の後に event: metrics が付く Streaming Events
レート制限は組織単位で、複数のバケットがある Rate Limits
ai& Inferenceは国内インフラ、OpenAI/Claude互換、低コスト、データ国内完結を訴求している ai& Inference
公式ページにはQwen3.6-27B、DeepSeek V4 Flash、gpt-oss-120b、GLM-5.2などの料金例が掲載されている ai& Inference

価格やモデル一覧は変わります。記事の読者には、投稿時点の表だけで判断せず、実行前に GET /v1/models と公式Pricingを見ることをおすすめします。

まとめ

ai& Inferenceは、OpenAI互換APIとして見れば「base URLを変えるだけ」で始められます。

でも、エージェント用途でおいしいのはその先です。モデルを1つに固定せず、分類、計画、ツール実行、最終回答で分ける。コストと遅延をリクエスト単位で取る。ストリーミングやレート制限も最初から運用に入れる。

これをやると、AIエージェントは「動いたデモ」から「育てられるシステム」に近づきます。

オープンモデルを国内の推論基盤からOpenAI互換で呼べるなら、既存の実装資産を捨てずに、モデル選択と運用設計だけを前に進められます。個人的には、ai& Inferenceの一番面白いところはそこだと思いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?