مقالات

ساخت یک روتر مقرون به صرفه LLM در Node.js (Opus 5.5 و GPT-6 Sol)


تیم‌هایی که برای هر درخواست خروجی یک مدل را کد می‌کنند، پیامدهای آن را در صورت‌حساب‌های ماهانه خود می‌یابند. این مقاله به ساخت یک ماژول می پردازد ModelRouter کلاس در Node.js 22.6+ و TypeScript 5.x که به صورت پویا بین GPT-6 Sol و Claude Opus 5.5 انتخاب می کند، ارزیابی ارزش رمز را قبل از خروجی، قطع کننده های مدار آگاه از جریان، و سطوح بازگشتی خودکار اجرا می کند.

چگونه یک روتر LLM مقرون به صرفه در Node.js بسازیم

  1. مقداردهی اولیه Node.js 22.6+ با TypeScript 5.x، ماژول های ESM و نصب وابستگی های OpenAI، Anthropic، Zod، Pino و tiktoken.
  2. تعریف کنید پیکربندی‌های سطح مدل با قیمت‌گذاری رمز، پنجره‌های زمینه، و آرایه‌های قابلیت تأیید شده توسط طرحواره‌های Zod.
  3. ساخت و ساز یک تخمین‌گر هزینه توکن که نشانه‌های ورودی را از طریق tiktoken شمارش می‌کند، هزینه‌های خروجی را با ضرب‌کننده‌های نوع کار پروژه‌سازی می‌کند و هزینه‌ها را در یک دفتر کل 30 روزه ردیابی می‌کند.
  4. اجرا کنند یک امتیازدهنده پیچیدگی که طول سریع، برچسب‌های چند مرحله‌ای، کلمات کلیدی کد، الزامات خروجی ساختاریافته و عمق چرخش را برای ایجاد امتیاز مسیریابی 0 تا 100 ارزیابی می‌کند.
  5. ایجاد کنید یک سوئیچ استریم که شکاف‌های زمان تا توکن اول و بین نشانه را کنترل می‌کند و بین حالت‌های بسته، نیمه باز و باز سوئیچ می‌کند.
  6. جمع آوری کنید کلاس ModelRouter به عنوان یک ژنراتور ناهمزمان که پیچیدگی را ارزیابی می کند، سلامت مدار را بررسی می کند، هزینه را تخمین می زند، لایه بهینه را انتخاب می کند و پاسخ ها را جریان می دهد.
  7. پخش می شود تله متری JSON ساختاریافته برای هر درخواست، ثبت انتخاب مدل، شمارش توکن، واریانس هزینه، تأخیر و وضعیت قطع از طریق Pino.
  8. اعتبار سنجی راه‌حل‌های مسیریابی با تست‌های یکپارچه‌سازی با استفاده از اعلان‌های کنتراست برای تأیید انتخاب سطح، رفتار برگشتی و خروجی تله متری.

محتوا

چرا مسیریابی استاتیک LLM یک قاتل بودجه است

تیم هایی که برای هر درخواست استنتاج یک مدل را کد می کنند، عواقب آن را در صورتحساب های ماهانه خود می یابند. مسیریابی هر کار جمع‌آوری، درخواست طبقه‌بندی، و پرسش و پاسخ ساده از طریق مدل مرزی مانند Claude Opus 5.5 باعث هدر رفتن هزینه‌های بارهای کاری می‌شود که سطح ارزان‌تر با کیفیتی معادل انجام می‌دهد. توکن‌های هسته‌ای Claude Opus 5.5 تقریباً 2 برابر بیشتر از توکن‌های بذر GPT-6 Sol بر اساس قیمت‌های منتشر شده فعلی، قیمت دارند. مشکل معکوس نیز به همان اندازه مخرب است: هدایت وظایف استدلال چند مرحله‌ای پیچیده به مدلی که فاقد قابلیت است منجر به خروجی ضعیف، تلاش‌های مجدد هدر رفته و هزینه‌های پنهان کیفیت می‌شود.

مسیریابی ارائه دهنده استاتیک باعث افزایش هزینه های فاجعه بار برای کارهای پیچیده و هدر رفتن پول برای کارهای ساده می شود. راه حل یک روتر LLM آگاه از هزینه است که برای هر درخواست تصمیم می گیرد که کدام مدل را بر اساس هزینه توکن تخمینی، وضعیت تأخیر و پیچیدگی کار فراخوانی کند.

این مقاله به ساخت یک ماژول می پردازد ModelRouter کلاس در Node.js 22.6+ و TypeScript 5.x که به صورت پویا بین GPT-6 Sol و Claude Opus 5.5 انتخاب می کند. روتر ارزیابی ارزش توکن پیش خروجی، قطع کننده های مدار آگاه از جریان و سطوح بازگشتی خودکار را پیاده سازی می کند. نتیجه نهایی یک دروازه کاملاً قابل صادرات با تله متری JSON ساختار یافته برای تولید است.

نکته در مورد نسخه Node.js: را --experimental-strip-types پرچم استفاده شده در dev اسکریپت در Node.js 22.6.0 به عنوان یک ویژگی آزمایشی معرفی شد. لطفاً قبل از استفاده از Node.js وضعیت پایداری آن را برای نسخه خاص خود از Node.js تأیید کنید.

نمای کلی معماری: یک روتر مقرون به صرفه چگونه کار می کند

خط لوله ای برای طبقه بندی درخواست ها

هر اعلان دریافتی از یک خط لوله پنج مرحله ای عبور می کند: یک تخمینگر پیچیدگی، یک برآوردگر قیمت نشانه، یک انتخابگر مدل، یک جریان پاسخ و یک لاگر تله متری. روتر قبل از انتخاب یک مدل، هر درخواست را بر اساس سه محور تصمیم ارزیابی می کند.

سقف هزینه تعیین می کند که آیا توکن های درخواستی هزینه کمتری نسبت به بودجه رایگان باقی مانده دارند یا خیر. تأخیر SLA سلامت شکن مدل ترجیحی را بر اساس اندازه‌گیری‌های اخیر زمان به اولین نشانه (TTFT) تأیید می‌کند. الزامات قابلیت با نمره پیچیدگی کار در برابر قابلیت‌های سطح مدل مطابقت دارد و تضمین می‌کند که استدلال چند مرحله‌ای و وظایف با زمینه طولانی به مدل‌هایی که برای رسیدگی به آنها مجهز شده‌اند هدایت می‌شوند.

تعاریف سطح مدل

روتر دو سطح را تعریف می کند. Tier 1 GPT-6 Sol است: هزینه کمتر برای هر توکن، با پوشش گسترده در تجمیع، طبقه بندی و تولید یکباره زیر 2000 توکن ورودی. سطح 2، Claude Opus 5.5، هزینه بیشتری برای هر توکن دارد، اما با توجه به آزمایش نویسنده، استدلال چند مرحله‌ای، ترکیب بافت طولانی، و وظایفی را که نیاز به انسجام پایدار در پنجره‌های ورودی بزرگ دارند، بهتر از ردیف 1 انجام می‌دهد.

مفروضات قیمت گذاری توکن تعبیه شده در پیکربندی روتر به عنوان مبنای اقتصادی هر تصمیم مسیریابی عمل می کند. هنگامی که قیمت های تامین کننده تغییر می کند، این مقادیر را به روز کنید.

ساختار فایل پروژه

قبل از ورود به کد، در اینجا طرح بندی فایل برای پروژه آمده است. هر بلوک کد زیر با نام فایل هدف برچسب گذاری شده است:

router-project/
├── .env.example
├── .env
├── package.json
├── tsconfig.json
└── src/
    ├── config.ts
    ├── env.ts
    ├── estimator.ts
    ├── scorer.ts
    ├── breaker.ts
    ├── telemetry.ts
    ├── router.ts
    └── demo.ts

src/config.ts

import { z } from "zod";

export enum ModelTier {
  TIER_1 = "TIER_1",
  TIER_2 = "TIER_2",
}

const ModelConfigSchema = z.object({
  modelId: z.string(),
  tier: z.nativeEnum(ModelTier),
  inputPricePer1kTokens: z.number().positive(),
  outputPricePer1kTokens: z.number().positive(),
  maxContextWindow: z.number().int().positive(),
  capabilities: z.array(z.string()),
});

export type ModelConfig = z.infertypeof ModelConfigSchema>;






export const MODEL_CONFIG: RecordModelTier, ModelConfig> = {
  [ModelTier.TIER_1]: ModelConfigSchema.parse({
    modelId: "gpt-6-sol",
    tier: ModelTier.TIER_1,
    inputPricePer1kTokens: 0.002,
    outputPricePer1kTokens: 0.01,
    maxContextWindow: 1_050_000,
    capabilities: ["summarization", "classification", "general-qa", "code-completion"],
  }),
  [ModelTier.TIER_2]: ModelConfigSchema.parse({
    modelId: "claude-opus-5-5",
    tier: ModelTier.TIER_2,
    inputPricePer1kTokens: 0.004,
    outputPricePer1kTokens: 0.02,
    maxContextWindow: 1_000_000,
    capabilities: [
      "multi-step-reasoning",
      "long-context-synthesis",
      "code-generation",
      "structured-output",
      "chain-of-thought",
    ],
  }),
};

طرح Zod فرمت پیکربندی را در زمان اجرا تأیید می‌کند، و پیکربندی‌های نادرست را در هنگام راه‌اندازی به جای زمان خروجی تشخیص می‌دهد.

راه اندازی پروژه و وابستگی ها

پروژه را راه اندازی کنید

این پروژه Node.js 22.6+ را با TypeScript 5.x در حالت سخت هدف قرار می دهد. مطمئن شوید package.json را شامل می شود "type": "module" برای فعال کردن ESM بومی همانطور که همه واردات استفاده می کنند .js پسوند پس از nodenext کنوانسیون وضوح ماژول

وابستگی های اصلی را نصب کنید:

npm install openai @anthropic-ai/sdk zod pino dotenv tiktoken
npm install -D typescript @types/node

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

متغیرهای محیطی و کلیدهای API

روتر به کلیدهای API برای هر دو ارائه‌دهنده، بودجه رمز ماهانه که بر حسب USD بیان می‌شود، و آستانه تأخیر در میلی‌ثانیه که بر رفتار سوئیچ حاکم است، نیاز دارد.

.env.example

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
MONTHLY_TOKEN_BUDGET_USD=50
LATENCY_THRESHOLD_MS=3000

یک کپی .env.example به .env و مقادیر واقعی خود را قبل از اجرای پروژه پر کنید.

src/env.ts

import { z } from "zod";
import dotenv from "dotenv";

dotenv.config();

const EnvConfigSchema = z.object({
  OPENAI_API_KEY: z.string().min(1, "OPENAI_API_KEY is required"),
  ANTHROPIC_API_KEY: z.string().min(1, "ANTHROPIC_API_KEY is required"),
  MONTHLY_TOKEN_BUDGET_USD: z.coerce.number().positive("Budget must be positive"),
  LATENCY_THRESHOLD_MS: z.coerce.number().int().positive().default(3000),
});

export type EnvConfig = z.infertypeof EnvConfigSchema>;

export const env: EnvConfig = EnvConfigSchema.parse(process.env);

اسکریپت های زیر را به خود اضافه کنید package.json:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/demo.js",
    "dev": "node --experimental-strip-types --watch src/demo.ts",
    "test": "node --experimental-strip-types src/demo.ts"
  }
}

طرح Zod هر متغیر محیطی را در هنگام راه اندازی تجزیه و بررسی می کند. اگر MONTHLY_TOKEN_BUDGET_USD از دست رفته یا غیر عددی، فرآیند به جای ایجاد رفتار نامشخص در طول یک تصمیم مسیریابی پایین دست، بلافاصله با یک خطای قابل خواندن توسط انسان شکست می خورد.

نکته امنیتی: از ورود به صورت خام خودداری کنید process.env مقادیر یا پیام های خطای ضدعفونی نشده، زیرا ممکن است حاوی کلیدهای API یا سایر داده های حساس باشند.

ساخت تخمین‌گر هزینه رمز

شمارش رمز قبل از نتیجه گیری

برآورد دقیق هزینه نیاز به شمارش نشانه های ورودی قبل از فراخوانی API دارد. برای GPT-6 Sol، tiktoken رمزگذاری توکنایزر مطابق با مدل‌های OpenAI ارائه می‌کند: encoding_for_model("gpt-6-sol") زمانی که نسخه tiktoken شما الگو را بداند، کدگذاری صحیح را برمی‌گرداند. در نسخه های قدیمی تر، به آن برگردید o200k_base کدگذاری مورد استفاده توسط مدل‌های OpenAI اخیر و بررسی امتیازات در برابر متادیتای استفاده که API برمی‌گرداند. برای Claude Opus 5.5، SDK Anthropic یک نقطه پایانی شمارش رمز را نشان می دهد. به عنوان یک اکتشافی بازگشتی، تقسیم تعداد کاراکترها بر 3.5، نشانه گذاری آنتروپیک را برای متن انگلیسی تقریبی می کند. این اکتشافی برای متن های غیر انگلیسی یا درخواست هایی با کدهای زیاد غیرقابل اعتماد است. برای دقت در این موارد از نقطه پایانی API شمارش توکن Anthropic استفاده کنید.

توجه: اگر tiktoken مقداردهی اولیه نمی شود (مثلاً خطاهای مربوط به WASM)، مطمئن شوید که نسخه Node.js شما از ماژول های WASM پشتیبانی می کند یا از آن استفاده می کند. js-tiktoken جایگزین خالص JS

شما نمی توانید تعداد نشانه های خروجی را قبل از تکمیل تماس بدانید، بنابراین ارزیاب از یک ضرب کننده قابل تنظیم برای هر نوع کار استفاده می کند. تخمین‌گر پیش‌فرض برای کارهای خلاصه‌سازی 0.3 برابر است (قابل تنظیم از طریق outputMultipliers) مشاغل تولید کد می توانند 1.5 برابر یا بیشتر تولید کنند.

ردیابی بودجه با یک پنجره کشویی

ارزیاب یک دفترچه هزینه 30 روزه را در حافظه نگه می دارد. پس از تکمیل هر درخواست مهر شده با زمان جاری، قیمت واقعی را ثبت می کند. هنگامی که هزینه تخمینی مدل ترجیحی از بودجه باقیمانده برای پنجره فعلی بیشتر شود، برآوردگر یک کاهش اجباری را نشان می دهد. برای استقرار چند نمونه‌ای، این کتاب پخش درون حافظه را با Redis یا SQLite جایگزین کنید، اما استقرارهای درون حافظه برای خدمات تک‌فرآیند به درستی کار می‌کنند.

هشدار: کتاب درون حافظه با تماس‌های ناهمزمان هم‌پوشانی همزمان امن نیست route(). در همزمانی بالا، شرایط مسابقه بین filter و push در آرایه دفتر کل می تواند منجر به قرائت نادرست بودجه شود. برای استفاده در تولید با درخواست های همزمان، از یک صف، mutex یا ذخیره داده خارجی استفاده کنید.

src/estimator.ts

import { encoding_for_model, type Tiktoken } from "tiktoken";
import { MODEL_CONFIG, ModelTier, type ModelConfig } from "./config.js";
import { env } from "./env.js";

interface SpendEntry {
  costUsd: number;
  timestamp: number;
}

export class TokenCostEstimator {
  private spendLedger: SpendEntry[] = [];
  private static _enc: Tiktoken | null = null;
  private outputMultipliers: Recordstring, number> = {
    summarization: 0.3,
    "code-generation": 1.5,
    "general-qa": 0.5,
    "multi-step-reasoning": 1.0,
    default: 0.6,
  };

  private getEncoder(): Tiktoken {
    if (!TokenCostEstimator._enc) {
      TokenCostEstimator._enc = encoding_for_model("gpt-6-sol");
    }
    return TokenCostEstimator._enc;
  }

  estimateInputTokens(prompt: string, model: ModelConfig): number {
    if (model.tier === ModelTier.TIER_1) {
      
      
      const enc = this.getEncoder();
      return enc.encode(prompt).length;
      
    }
    
    
    return Math.ceil(prompt.length / 3.5);
  }

  projectCost(
    inputTokens: number,
    taskType: string,
    model: ModelConfig
  ): { estimatedCostUsd: number; expectedOutputTokens: number } {
    const multiplier = this.outputMultipliers[taskType] ?? this.outputMultipliers.default;
    const expectedOutputTokens = Math.ceil(inputTokens * multiplier);
    const inputCost = (inputTokens / 1000) * model.inputPricePer1kTokens;
    const outputCost = (expectedOutputTokens / 1000) * model.outputPricePer1kTokens;
    return { estimatedCostUsd: inputCost + outputCost, expectedOutputTokens };
  }

  isWithinBudget(projectedCostUsd: number): boolean {
    const now = Date.now();
    const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
    this.spendLedger = this.spendLedger.filter((e) => e.timestamp > thirtyDaysAgo);
    const totalSpent = this.spendLedger.reduce((sum, e) => sum + e.costUsd, 0);
    return totalSpent + projectedCostUsd  env.MONTHLY_TOKEN_BUDGET_USD;
  }

  recordSpend(costUsd: number): void {
    this.spendLedger.push({ costUsd, timestamp: Date.now() });
  }
}

پیاده سازی نمره پیچیدگی

قوانین امتیازدهی اکتشافی

امتیاز پیچیدگی با استخراج و وزن دادن پنج مشخصه سریع، مقداری از 0 تا 100 را اختصاص می دهد. طول اعلان به عنوان یک پروکسی برای پیچیدگی عمل می کند (وزن امتیاز طولانی تر، به عنوان یک فرض طراحی به جای علیت تأیید شده، بیشتر را نشان می دهد). چهار ویژگی باقیمانده عبارتند از: وجود نشانگرهای دستورالعمل چند مرحله ای مانند “اول”، “سپس” و “در نهایت”. کلمات کلیدی تولید کد مانند “پیاده سازی”، “عملکرد” ​​یا “کلاس”؛ الزامات خروجی ساختاریافته (JSON، جدول، طرحواره)؛ و گفتگو به عمق تبدیل می شود، جایی که گفتگوهای عمیق تر مستلزم استدلال زمینه ای بیشتری است.

آستانه مسیریابی 60 است. درخواست هایی با امتیاز 60 یا بالاتر از مسیر به سطح 2 (Claude Opus 5.5). مسیر زیر 60 را به سطح 1 (GPT-6 Sol) می دهد.

src/scorer.ts

interface ComplexityBreakdown {
  lengthScore: number;
  multiStepScore: number;
  codeGenScore: number;
  structuredOutputScore: number;
  turnDepthScore: number;
  total: number;
}

const stepRegexes: RegExp[] = [/first/i, /then/i, /next/i, /after that/i, /finally/i, /step \d/i];
const codeKeywords = ["implement", "function", "class", "algorithm", "refactor", "debug"];
const structuredMarkers = ["json", "table", "schema", "csv", "xml", "yaml"];

export function scoreComplexity(prompt: string, turnDepth: number): ComplexityBreakdown {
  const lower = prompt.toLowerCase();

  
  const lengthScore = Math.min(20, Math.floor(prompt.length / 500) * 4);

  
  const stepMatches = stepRegexes.filter((r) => r.test(lower)).length;
  const multiStepScore = Math.min(25, stepMatches * 8);

  
  const codeMatches = codeKeywords.filter((k) => lower.includes(k)).length;
  const codeGenScore = Math.min(20, codeMatches * 7);

  
  const structuredMatches = structuredMarkers.filter((s) => lower.includes(s)).length;
  const structuredOutputScore = Math.min(15, structuredMatches * 8);

  
  const turnDepthScore = Math.min(20, turnDepth * 4);

  const total = lengthScore + multiStepScore + codeGenScore + structuredOutputScore + turnDepthScore;

  return {
    lengthScore,
    multiStepScore,
    codeGenScore,
    structuredOutputScore,
    turnDepthScore,
    total: Math.min(100, total),
  };
}

تابع هم نتیجه عددی و هم یک شی تجزیه را برمی گرداند. خرابی مستقیماً به تله متری وارد می شود و به اپراتورها می دهد که چرا روتر سطح خاصی را برای یک درخواست معین انتخاب کرده است.

یک قطع کننده جریان ایجاد کنید

چرا جریان به مدارشکن خودش نیاز دارد

وقفه های استاندارد HTTP موفقیت یا شکست را پس از پاسخ کامل ارزیابی می کنند. استریم پاسخ‌های LLM یک حالت شکست را معرفی می‌کند که وقفه‌های معمولی از دست می‌دهند: یک مدل می‌تواند با موفقیت توکن‌ها را پخش کند، سپس پاسخ میانی را متوقف کند. به نظر می رسد درخواستی که 400 توکن قبل از hanging دریافت کرده باشد برای یک بریکر معمولی موفقیت آمیز است، اما خروجی ناقص تولید می کند و جریان پاسخ را مسدود می کند.

استریم پاسخ‌های LLM یک حالت شکست را معرفی می‌کند که وقفه‌های معمولی از دست می‌دهند: یک مدل می‌تواند با موفقیت توکن‌ها را پخش کند، سپس پاسخ میانی را متوقف کند.

سوئیچ استریم سه حالت را دنبال می‌کند: بسته (سالم، همه درخواست‌ها ادامه دارند)، نیمه باز (کاوش با یک درخواست پس از خنک شدن)، و باز (مسدود، همه درخواست‌ها به مدل پشتیبان می‌روند).

تاخیر و آستانه خطا

دو معیار بر انتقال حالت حاکم است: زمان به نشانه اول (TTFT) و فاصله بین نشانه. اگر TTFT بیشتر از پیکربندی شده باشد LATENCY_THRESHOLD_MS برای تعداد قابل تنظیم درخواست های متوالی، قطع کننده مدار در موقعیت OPEN حرکت می کند. قبل از اینکه مدارشکن به حالت نیمه باز برود و اجازه یک درخواست پروب را بدهد، باید یک دوره خنک شدن بگذرد.

src/breaker.ts

type BreakerState = "CLOSED" | "HALF-OPEN" | "OPEN";

export class CircuitBreaker {
  private _state: BreakerState = "CLOSED";
  private failureCount = 0;
  private lastFailureTime = 0;
  private readonly failureThreshold: number;
  private readonly cooldownMs: number;
  private readonly latencyThresholdMs: number;

  constructor(failureThreshold = 3, cooldownMs = 30_000, latencyThresholdMs = 3000) {
    this.failureThreshold = failureThreshold;
    this.cooldownMs = cooldownMs;
    this.latencyThresholdMs = latencyThresholdMs;
  }

  get state(): BreakerState {
    
    if (this._state === "OPEN" && Date.now() - this.lastFailureTime > this.cooldownMs) {
      return "HALF-OPEN";
    }
    return this._state;
  }

  canRequest(): boolean {
    const current = this.state;
    if (current === "HALF-OPEN") {
      
      this._state = "HALF-OPEN";
    }
    return current === "CLOSED" || current === "HALF-OPEN";
  }

  recordSuccess(ttftMs: number): void {
    if (ttftMs > this.latencyThresholdMs) {
      this.recordFailure(new Error(`TTFT ${ttftMs}ms exceeded threshold`));
      return;
    }
    this.failureCount = 0;
    this._state = "CLOSED";
  }

  recordFailure(error: Error): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.failureThreshold) {
      this._state = "OPEN";
    }
  }

  createStreamTimeoutController(): {
    controller: AbortController;
    resetTimer: () => void;
    cancel: () => void;
  } {
    const controller = new AbortController();
    let timer: ReturnTypetypeof setTimeout>;

    const resetTimer = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        controller.abort(new Error("Inter-token gap exceeded threshold"));
        this.recordFailure(new Error("Stream stall detected"));
      }, this.latencyThresholdMs);
    };

    const cancel = () => {
      clearTimeout(timer);
    };

    resetTimer(); 
    return { controller, resetTimer, cancel };
  }
}

را createStreamTimeoutController روش an را برمی گرداند AbortController جفت شده با a resetTimer عملکرد و الف cancel تابع کد مصرف کننده نامیده می شود resetTimer() هر بار که یک ژتون می رسد. اگر اختلاف بین توکن ها از آستانه فراتر رود، کنترل کننده جریان را می شکند و شکن شکست را ثبت می کند. را cancel() برای پاک کردن تایمر و جلوگیری از پایان نادرست، باید زمانی که رشته با موفقیت کامل شد، تابع را فراخوانی کرد.

کلاس ModelRouter: جمع کردن همه چیز

جریان تصمیم گیری روتر

را ModelRouter الف را می پذیرد RouterRequest حاوی درخواست، تاریخچه مکالمه، اشاره نوع کار، و نادیده گرفتن حداکثر بودجه اختیاری است. سپس خط لوله تصمیم کامل را اجرا می کند: تخمین پیچیدگی، بررسی وضعیت شکن برای مدل ترجیحی، اجرای برآوردگر هزینه، انتخاب مدل، فراخوانی SDK مناسب، جریان پاسخ، و ضبط تله متری.

منطق پشتیبان گیری سطح خودکار

روتر به صراحت سه سناریوی بازگشتی را کنترل می کند. اگر مدار شکن مدل انتخابی باز باشد، روتر به سطح متناوب باز می گردد. اگر هر دو سوئیچ باز باشند، روتر یک قسمت خطای ساختاریافته را با فاصله زمانی مجدد خروجی می دهد. اگر برآورد هزینه از بودجه بیشتر باشد، اما برآورد پیچیدگی به سطح 2 نیاز دارد، روتر پنجره زمینه را کوتاه می‌کند و قبل از بازگشت به سطح 1 دوباره ارزیابی می‌کند.

src/router.ts

import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
import pino from "pino";
import { MODEL_CONFIG, ModelTier, type ModelConfig } from "./config.js";
import { TokenCostEstimator } from "./estimator.js";
import { CircuitBreaker } from "./breaker.js";
import { scoreComplexity } from "./scorer.js";
import { emitTelemetry, type TelemetryRecord } from "./telemetry.js";
import { env } from "./env.js";

export interface RouterRequest {
  prompt: string;
  conversationHistory?: Array{ role: string; content: string }>;
  taskType?: string;
  maxBudgetOverrideUsd?: number;
}

export interface StreamChunk {
  type: "token" | "error" | "done";
  content: string;
  model?: string;
}

function getBreakerOrThrow(
  breakers: MapModelTier, CircuitBreaker>,
  tier: ModelTier
): CircuitBreaker {
  const b = breakers.get(tier);
  if (!b) throw new Error(`No CircuitBreaker registered for tier: ${tier}`);
  return b;
}

type ChatMessage = { role: "user" | "assistant" | "system"; content: string };

export class ModelRouter {
  private openai: OpenAI;
  private anthropic: Anthropic;
  private breakers: MapModelTier, CircuitBreaker>;
  private estimator: TokenCostEstimator;
  private logger: pino.Logger;

  constructor(
    estimator: TokenCostEstimator,
    breakers: MapModelTier, CircuitBreaker>,
    logger: pino.Logger
  ) {
    this.openai = new OpenAI({ apiKey: env.OPENAI_API_KEY });
    this.anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
    this.estimator = estimator;
    this.breakers = breakers;
    this.logger = logger;
  }

  async *route(request: RouterRequest): AsyncGeneratorStreamChunk> {
    const taskType = request.taskType ?? "default";
    const turnDepth = request.conversationHistory?.length ?? 0;
    const complexity = scoreComplexity(request.prompt, turnDepth);
    let preferredTier = complexity.total >= 60 ? ModelTier.TIER_2 : ModelTier.TIER_1;
    let selectedConfig = MODEL_CONFIG[preferredTier];
    const inputTokens = this.estimator.estimateInputTokens(request.prompt, selectedConfig);
    let { estimatedCostUsd, expectedOutputTokens } = this.estimator.projectCost(inputTokens, taskType, selectedConfig);

    
    if (!this.estimator.isWithinBudget(estimatedCostUsd) && preferredTier === ModelTier.TIER_2) {
      preferredTier = ModelTier.TIER_1;
      selectedConfig = MODEL_CONFIG[preferredTier];
      ({ estimatedCostUsd, expectedOutputTokens } = this.estimator.projectCost(inputTokens, taskType, selectedConfig));
    }

    
    let breaker = getBreakerOrThrow(this.breakers, preferredTier);
    let fallbackTriggered = false;
    if (!breaker.canRequest()) {
      const alternateTier = preferredTier === ModelTier.TIER_1 ? ModelTier.TIER_2 : ModelTier.TIER_1;
      const alternateBreaker = getBreakerOrThrow(this.breakers, alternateTier);
      if (!alternateBreaker.canRequest()) {
        yield { type: "error", content: '{"error":"all_models_unavailable","retryAfterMs":30000}' };
        return;
      }
      preferredTier = alternateTier;
      selectedConfig = MODEL_CONFIG[preferredTier];
      
      ({ estimatedCostUsd, expectedOutputTokens } = this.estimator.projectCost(inputTokens, taskType, selectedConfig));
      fallbackTriggered = true;
    }

    
    breaker = getBreakerOrThrow(this.breakers, preferredTier);

    
    const historyMessages: ChatMessage[] = (request.conversationHistory ?? []).map((m) => ({
      role: m.role as "user" | "assistant" | "system",
      content: m.content,
    }));
    const fullMessages: ChatMessage[] = [
      ...historyMessages,
      { role: "user", content: request.prompt },
    ];

    const startTime = Date.now();
    let actualOutputTokens = 0;
    let ttftMs = 0;
    let firstToken = true;
    let streamCompleted = false;
    let requestSucceeded = false;
    const { controller, resetTimer, cancel } = breaker.createStreamTimeoutController();

    try {
      if (preferredTier === ModelTier.TIER_1) {
        const stream = await this.openai.chat.completions.create(
          { model: selectedConfig.modelId, messages: fullMessages, stream: true, stream_options: { include_usage: true } },
          { signal: controller.signal }
        );
        let usageOutputTokens: number | undefined;
        for await (const chunk of stream) {
          resetTimer();
          if (firstToken) {
            ttftMs = Date.now() - startTime;
            firstToken = false;
          }
          const token = chunk.choices[0]?.delta?.content ?? "";
          if (token) {
            actualOutputTokens += token.length;
            yield { type: "token", content: token, model: selectedConfig.modelId };
          }
          if (chunk.usage?.completion_tokens) {
            usageOutputTokens = chunk.usage.completion_tokens;
          }
        }
        
        
        actualOutputTokens = usageOutputTokens ?? Math.ceil(actualOutputTokens / 4);
      } else {
        
        
        const stream = this.anthropic.messages.stream({
          model: selectedConfig.modelId,
          max_tokens: expectedOutputTokens,
          messages: fullMessages,
        });

        
        controller.signal.addEventListener("abort", () => {
          stream.abort();
        }, { once: true });

        for await (const event of stream) {
          resetTimer();
          if (firstToken) {
            ttftMs = Date.now() - startTime;
            firstToken = false;
          }
          
          
          if (
            event.type === "content_block_delta" &&
            event.delta &&
            "text" in event.delta
          ) {
            const text = (event.delta as { type: string; text: string }).text;
            actualOutputTokens += text.length;
            yield { type: "token", content: text, model: selectedConfig.modelId };
          }
        }
        
        actualOutputTokens = Math.ceil(actualOutputTokens / 4);
      }
      cancel(); 
      streamCompleted = true;
      requestSucceeded = true;
      breaker.recordSuccess(ttftMs);
    } catch (err: unknown) {
      cancel(); 
      breaker.recordFailure(err instanceof Error ? err : new Error(String(err)));
      
      const safeMessage =
        err instanceof Error
          ? err.message.length > 200
            ? err.message.slice(0, 200) + "...[truncated]"
            : err.message
          : "Unknown error";
      yield { type: "error", content: safeMessage };
    }

    
    if (requestSucceeded) {
      const inputCost = (inputTokens / 1000) * selectedConfig.inputPricePer1kTokens;
      const outputCost = (actualOutputTokens / 1000) * selectedConfig.outputPricePer1kTokens;
      const actualCostUsd = inputCost + outputCost;
      this.estimator.recordSpend(actualCostUsd);
    }

    const inputCost = (inputTokens / 1000) * selectedConfig.inputPricePer1kTokens;
    const outputCost = (actualOutputTokens / 1000) * selectedConfig.outputPricePer1kTokens;
    const actualCostUsd = inputCost + outputCost;
    const totalLatencyMs = Date.now() - startTime;
    const telemetry: TelemetryRecord = {
      selectedModel: selectedConfig.modelId, fallbackTriggered, inputTokens, outputTokens: actualOutputTokens,
      estimatedCostUsd, actualCostUsd, ttftMs,
      totalLatencyMs, complexityScore: complexity.total,
      breakerState: breaker.state, timestamp: new Date().toISOString(),
    };
    emitTelemetry(telemetry, this.logger);
    yield { type: "done", content: "", model: selectedConfig.modelId };
  }
}

را route روش یک ژنراتور ناهمزمان است که به عقب باز می گردد StreamChunk اشیاء به محض رسیدن توکن ها این طراحی به مصرف کننده کد اجازه می دهد تا قطعات را مستقیماً به یک جریان پاسخ HTTP، WebSocket یا گیرنده دیگر بدون بافر کردن پاسخ کامل در حافظه ارسال کند.

قابلیت تله متری و مانیتورینگ ساختاریافته JSON

چه چیزی ثبت نام کنیم

هر درخواست یک رکورد تله‌متری ایجاد می‌کند که مدل انتخاب‌شده، راه‌اندازی مجدد، تعداد توکن‌های وارد شده و خروجی، تخمین زده‌شده در مقابل قیمت واقعی، زمان تا اولین نشانه، کل تاخیر، رتبه‌بندی پیچیدگی و وضعیت شکن پس از تکمیل را نشان می‌دهد.

صادرات تله متری

پینو JSON ساخت یافته را در stdout می نویسد. عامل تحویل گزارش خود (Fluent Bit، Datadog Agent، CloudWatch Agent، و غیره) را پیکربندی کنید تا stdout را به پلتفرم نظارتی شما ارسال کند.

src/telemetry.ts

import { z } from "zod";
import pino from "pino";

export const TelemetryRecordSchema = z.object({
  selectedModel: z.string(),
  fallbackTriggered: z.boolean(),
  inputTokens: z.number().int(),
  outputTokens: z.number().int(),
  estimatedCostUsd: z.number(),
  actualCostUsd: z.number(),
  ttftMs: z.number(),
  totalLatencyMs: z.number(),
  complexityScore: z.number(),
  breakerState: z.enum(["CLOSED", "HALF-OPEN", "OPEN"]),
  timestamp: z.string().datetime(),
});

export type TelemetryRecord = z.infertypeof TelemetryRecordSchema>;

export function emitTelemetry(record: TelemetryRecord, logger: pino.Logger): void {
  const validated = TelemetryRecordSchema.parse(record);
  logger.info({ event: "llm_route_complete", ...validated });
}





تست یکپارچه سازی: مسیریابی سریع برای استدلال چند مرحله ای

نسخه ی نمایشی از دو دستور متضاد برای تایید منطق مسیریابی استفاده می کند. اولی یک کار خلاصه سازی ساده است (“این پاراگراف را در دو جمله خلاصه کنید”) که باید امتیاز بسیار کمتری از 60 داشته باشد و به GPT-6 Sol اشاره کند. دومی اشاره ای به استدلال ریاضی در یک زنجیره فکری با نشانگرهای چند مرحله ای و الزامات خروجی ساختاریافته است، امتیاز 78 و به Claude Opus 5.5 مراجعه کنید.

در سناریوی دوم، شبیه‌سازی یک اسپایک تاخیری Claude Opus 5.5، بریکر را خاموش می‌کند و بازگشت خودکار به GPT-6 Sol را مجبور می‌کند. خروجی تله متری برای هر دو پا به وضوح منطق پشت تصمیم را نشان می دهد.

src/demo.ts

import pino from "pino";
import { TokenCostEstimator } from "./estimator.js";
import { CircuitBreaker } from "./breaker.js";
import { ModelRouter } from "./router.js";
import { ModelTier } from "./config.js";

const logger = pino({ level: "info" });
const estimator = new TokenCostEstimator();
const breakers = new MapModelTier, CircuitBreaker>([
  [ModelTier.TIER_1, new CircuitBreaker(3, 30_000, 3000)],
  [ModelTier.TIER_2, new CircuitBreaker(3, 30_000, 3000)],
]);

const router = new ModelRouter(estimator, breakers, logger);

async function runDemo() {
  console.log("--- Simple Summarization (expect GPT-6 Sol) ---");
  const simple = router.route({
    prompt: "Summarize the following paragraph in two sentences: Large language models have transformed...",
    taskType: "summarization",
  });
  for await (const chunk of simple) {
    if (chunk.type === "token") process.stdout.write(chunk.content);
    if (chunk.type === "done") console.log(`
[Routed to: ${chunk.model}]
`);
  }

  console.log("--- Complex Reasoning (expect Claude Opus 5.5) ---");
  const complex = router.route({
    prompt:
      "First, derive the formula for compound interest. Then, implement a TypeScript function that calculates it. Finally, output the results as a JSON table for principal values 1000, 5000, and 10000.",
    taskType: "multi-step-reasoning",
    conversationHistory: [
      { role: "user", content: "I need help with financial calculations." },
      { role: "assistant", content: "Sure, what do you need?" },
      { role: "user", content: "Let's work through compound interest step by step." },
    ],
  });
  for await (const chunk of complex) {
    if (chunk.type === "token") process.stdout.write(chunk.content);
    if (chunk.type === "done") console.log(`
[Routed to: ${chunk.model}]`);
    if (chunk.type === "error") console.error(`[Error: ${chunk.content}]`);
  }
}

runDemo().catch((err) => {
  logger.fatal(err, "Demo failed");
  process.exit(1);
});

اجرای این اسکریپت خطوط تله متری Pino JSON را برای هر دو درخواست تولید می کند که امتیاز پیچیدگی، مدل انتخاب شده، تعداد نشانه ها، برآورد هزینه و وضعیت شکن را برای هر تصمیم مسیریابی نشان می دهد.

نکات سخت شدن تولید

وضعیت بودجه پایدار

کتاب کار پنجره نورد درون حافظه برای استقرار تک فرآیندی کار می کند، اما خدمات چند نمونه ای در پشت یک متعادل کننده بار از دید مستقلی از هزینه ها پشتیبانی می کنند. انتقال دفتر کل به Redis (با استفاده از مجموعه‌های مرتب‌شده وارد شده بر اساس برچسب زمانی) یا SQLite یک بودجه ثابت را در همه نمونه‌ها اعمال می‌کند. هنگام راه اندازی مجدد فرآیند، کتاب حافظه نیز بازنشانی می شود.

هماهنگی محدودیت سرعت

هر دو سرصفحه محدودیت نرخ بازگشت OpenAI و Anthropic (x-ratelimit-remaining-requests، x-ratelimit-remaining-tokens) در پاسخ های SDK آنها. این مقادیر را به همراه معیارهای تأخیر به سوییچ وارد کنید تا از برخورد روتر به ارائه‌دهنده‌ای که به محدودیت RPM یا TPM خود نزدیک می‌شود، جلوگیری کنید. برای اطمینان از خرابی سریع، پاسخ محدود نرخ 429 را به عنوان خرابی قطع کننده مدار در نظر بگیرید.

مدل های بیشتری اضافه کنید

را ModelConfig طرح Zod افزودن سطح سوم را ساده می کند. یک مدل سبک وزن برای کارهای طبقه بندی بی اهمیت (به عنوان مثال یک مدل کوچک تنظیم شده با کسری از هزینه) نیاز به گسترش ModelTier enum، اضافه کردن یک ورودی جدید به MODEL_CONFIGو تنظیم آستانه پیچیدگی منطق روتر بازگشتی در حال حاضر از تناوب باینری بین دو سطح استفاده می‌کند و برای پشتیبانی از تکرار مرتب شده در بیش از دو سطح، نیاز به به‌روزرسانی دارد.

دام های رایج

  • شناسه‌های مدل ساختگی: همیشه قبل از استقرار، شناسه‌های مدل را با لیست مدل فعلی فروشنده بررسی کنید. نام مدل ها بین نسخه ها تغییر می کند.
  • قیمت منسوخ شده: قیمت های توکن در MODEL_CONFIG با به روز رسانی قیمت ها توسط تامین کنندگان تغییر خواهد کرد. صفحات رسمی قیمت گذاری (لینک به config.ts نظرات) قبل از هر استقرار.
  • بدون "type": "module" ج package.json، Node.js هر کدام را رد می کند .js واردات با ERR_REQUIRE_ESM. مطمئن شوید که ESM به درستی پیکربندی شده است.
  • فرم رویداد Anthropic SDK: ساختار رویداد جریان متفاوت است @anthropic-ai/sdk نسخه ها نسخه SDK خود را پین کنید و انواع رویدادها را با تعاریف TypeScript نسخه نصب شده بررسی کنید.

مراحل بعدی

را ModelRouter کلاس به تیم ها کنترل برنامه ای بر هزینه های LLM می دهد بدون اینکه کیفیت خروجی را در کارهایی که به قابلیت های مدل مرزی نیاز دارند به خطر بیندازند.

لایه کش معنایی (جاسازی یک اعلان هش برای پرش از استنتاج برای پرس و جوهای تکراری) تماس های اضافی را کاهش می دهد. نمرات کیفیت مدل برای آزمایش A/B در برابر یک معیار خاص مانند BLEU، دقت واقعی، یا میزان پذیرش برای یک کار خاص، تأیید می‌کند که آیا نتایج سطح 1 تا سطح 2 برای وظایف کم پیچیدگی باقی می‌ماند یا خیر. روتر همچنین به‌عنوان یک میان‌افزار Express یا Fastify در یک دروازه API قرار می‌گیرد.


Source link

نوشته های مشابه

دیدگاهتان را بنویسید

همچنین ببینید
بستن
دکمه بازگشت به بالا