cd ../back to blog
$Guide//June 4, 2026//7 min read

الـ streaming مع OpenAI SDK: تدفّق SSE واستهلاك توكنات صادق

تعلّم كيف يعمل تدفّق Server-Sent Events عبر نقطة نهاية متوافقة مع OpenAI: اضبط stream=True، وكرّر الـ chunks، واقرأ الـ delta، واحصل على أعداد توكنات حقيقية عبر include_usage. نفس الحلقة تعمل عبر Claude وGemini وGPT خلف base_url واحد على Brievio.

الـ streaming هو الفرق بين صندوق محادثة يبقى جامداً ثماني ثوانٍ وآخر يبدأ بالكتابة في أقل من ثانية. والآلية واحدة سواء كان النموذج خلف base_url الخاص بك هو Claude أو Gemini أو GPT: اضبط stream=True، وكرّر الـ chunks، واقرأ delta في كل واحد منها، وتوقّف عند علامة [DONE] الفاصلة. ولأنّ Brievio يتحدّث بروتوكول OpenAI Chat Completions، فإنّ نفس الحلقة بالضبط تعمل عبر كل نموذج أصلي حقيقي — تغيّر سلسلة model ولا شيء غير ذلك.

يتناول هذا المقال كيف يعمل تدفّق Server-Sent Events فعلاً عبر نقطة نهاية متوافقة مع OpenAI، وكيف تحصل على استهلاك توكنات دقيق على الـ chunk الأخير بواسطة stream_options، والنمط ذاته في Python وNode، وحالتَي الفشل الصامتتين اللتين تجعلان التدفّق يبدو سليماً بينما يخونك بهدوء: التدفّق المزيّف (المخزَّن مؤقتاً) والاستهلاك المفقود.

ماذا يعني "الـ streaming" عبر HTTP

النداء غير المتدفّق هو طلب واحد واستجابة واحدة: يفكّر الخادم بضع ثوانٍ، ثم يسلّمك الإكمال كاملاً دفعة واحدة. أما الـ streaming فيبقي اتصال HTTP مفتوحاً ويدفع الجواب قطعةً قطعةً بينما يولّده النموذج، مستخدماً Server-Sent Events (SSE). على السلك، تصل كل قطعة كسطر يبدأ بـ data: متبوعاً بكائن JSON، وينتهي التدفّق بسطر حرفيّ data: [DONE].

أنت لا تحلّل ذلك النص بنفسك إلا نادراً جداً — الـ SDK يتولّى ذلك. ما تحصل عليه في الشيفرة هو iterable من الـ chunks. يبدو كل chunk ككائن إكمال عادي إلا أنّ المحتوى يقيم في choices[0].delta بدلاً من choices[0].message، ولا يحمل سوى الجزء المولَّد منذ الـ chunk السابق. اربط كل delta.content بالترتيب وتكون قد أعدت بناء الرسالة كاملة. والمقياس الوحيد الذي يهمّ هنا هو زمن أول توكن (TTFB): كم يمضي قبل أن يظهر أول delta غير فارغ. هذا الرقم هو السبب الكامل وراء الـ streaming.

نمط Python

إليك الأمر كاملاً — حلقة تدفّق حقيقية تلتقط الاستهلاك أيضاً. والسطر الوحيد الخاص بـ Brievio هو base_url:

stream.py
from openai import OpenAI

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",   # base_url واحد، نماذج أصلية حقيقية
)

# stream=True يحوّل الاستجابة إلى تدفق Server-Sent Events.
# تكرّر الكائن؛ كل عنصر هو chunk واحد يحمل "delta" جزئياً.
stream = client.chat.completions.create(
    model="claude-sonnet-4-6",               # أو gemini-2.5-flash أو gpt-... إلخ.
    messages=[{"role": "user", "content": "Explain Raft consensus in 200 words."}],
    stream=True,
    stream_options={"include_usage": True},  # اطلب الاستهلاك على الـ chunk الأخير
)

usage = None
for chunk in stream:
    # آخر حدث data قبل [DONE] يحمل الاستهلاك وقائمة choices فارغة.
    if chunk.usage is not None:
        usage = chunk.usage
        continue
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)     # اعرض التوكنات فور وصولها

print()
# usage تتعبّأ فقط لأنّ include_usage مضبوط. هذه أعداد حقيقية.
print(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens)

ثلاث تفاصيل تعثّر الناس. أولاً، قد يكون delta المحتوى None أو فارغاً على بعض الـ chunks (الـ chunk الافتتاحي غالباً ما يضبط الدور فحسب)، فاحترس قبل الطباعة. ثانياً، الـ chunk الذي يحمل usage يأتي بعد انتهاء المحتوى وله قائمة choices فارغة — ولهذا يفحص المثال chunk.usage أولاً ثم يتابع بـ continue. ثالثاً، لست أنت من يبحث عن [DONE] بنفسه؛ فالـ SDK يستهلك تلك العلامة الفاصلة وينهي المكرّر نيابة عنك. ولو كنت تنادي نقطة النهاية بـ requests أو fetch الخام، لقسّمت على أسطر جديدة وكسرت عند [DONE] يدوياً.

الحلقة ذاتها في Node

الـ SDK في Node يكشف التدفّق كـ async iterable، فالبنية مطابقة — for await ... of بدلاً من for، وprocess.stdout.write بدلاً من print مع التفريغ:

stream.mjs
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: "sk-brievio-...",
  baseURL: "https://api.brievio.com/v1",
});

// نفس العقد في Node: stream=true يعيد async iterable من الـ chunks.
const stream = await client.chat.completions.create({
  model: "claude-sonnet-4-6",
  messages: [{ role: "user", content: "Explain Raft consensus in 200 words." }],
  stream: true,
  stream_options: { include_usage: true },
});

let usage = null;
for await (const chunk of stream) {
  // الحدث الأخير: choices فارغة، والاستهلاك حاضر.
  if (chunk.usage) {
    usage = chunk.usage;
    continue;
  }
  const delta = chunk.choices[0]?.delta?.content;
  if (delta) process.stdout.write(delta); // ادفع كل توكن إلى الطرفية
}

console.log();
console.log(usage?.prompt_tokens, usage?.completion_tokens, usage?.total_tokens);

لاحظ الـ optional chaining (chunk.choices[0]?.delta?.content). على الـ chunk الأخير الحامل للاستهلاك، تكون choices فارغة، لذا فإنّ الفهرسة بـ [0] دون الحارس سترمي خطأً عند خط النهاية تماماً. وهذا هو السبب الأكثر شيوعاً بمفرده لتعطّل معالج تدفّق Node على الحدث الأخير بعد أن بدا يعمل بإتقان طوال الاستجابة كلها.

الحصول على استهلاك حقيقي على الـ chunk الأخير

افتراضياً، لا تتضمّن الاستجابة المتدفّقة أعداد التوكنات — فليس هناك كائن usage على أيّ chunk. هذا جزء متعمَّد من بروتوكول OpenAI، وهو يلدغ الفرق التي تتدفّق في الإنتاج ثم تعجز عن مطابقة فاتورتها. والحلّ معامل واحد:

  • اضبط stream_options={"include_usage": True} (Python) أو stream_options: { include_usage: true } (Node).
  • عندئذٍ يصدر الخادم chunk إضافياً واحداً قبيل [DONE] تكون choices فيه فارغة ويحمل usage فيه prompt_tokens وcompletion_tokens و total_tokens.
  • على Brievio هذه هي الأعداد الحقيقية التي يبلّغ عنها النموذج — الأرقام ذاتها التي ستحصل عليها من نداء غير متدفّق، تُحاسَب بنحو 15% دون السعر الرسمي. لا يوجد كائن استهلاك محشوّ ولا system prompt محقون ينفخ جانب المدخلات.

إذا تخطّيت include_usage وما زلت بحاجة إلى تقدير للتوكنات، فخيارك الوحيد هو العدّ محلياً بـ tokenizer الخاص بالنموذج — وهو تقريبيّ وعبء صيانة. اضبط العَلَم فحسب.

الأعطال الصامتة: التدفّق المزيّف والاستهلاك المفقود

حالتا فشل تجتازان فحصاً عابراً بالعين ولا تظهران إلا تحت التدقيق. وكلتاهما تستحقّان فحصاً مدّته 20 ثانية قبل أن تأتمن gateway على حركة مرور حقيقية.

  • التدفّق "المزيّف" المخزَّن مؤقتاً. بعض الـ gateways تقبل stream=True، وتنتظر الإكمال العلوي بأكمله، ثم تعيد تشغيله لك دفعةً من الـ chunks في النهاية. تعمل حلقتك، وتصل الـ deltas، ويبدو كل شيء متدفّقاً — لكنّ الـ TTFB مطابق لنداء غير متدفّق لأنّ شيئاً لم يُرسَل حتى انتهى النموذج. والدليل بسيط: قِس الفجوة بين إرسال الطلب وأول delta غير فارغ. على التدفّق الحقيقي يقع في أقل من ثانية بكثير؛ وعلى الإعادة المخزَّنة يساوي زمن التوليد الكامل. فإذا كان زمن أول توكن يلاحق الزمن الكلي، فأنت لا تتدفّق، بل تشاهد تسجيلاً.
  • استهلاك مفقود أو ملفّق. الـ gateway الذي لا يحترم include_usage يتركك بلا أعداد توكنات على النداءات المتدفّقة — فتطابق فاتورتك مع الهواء. والأسوأ، أنّ gateway غير صادق قد يرفق كائن usage بأرقام منفوخة، لأنّ العميل على التدفّق نادراً ما يعيد العدّ. تحقّق منه بالطريقة المملّة: شغّل الـ prompt ذاته مرة متدفّقاً ومرة لا، وأكّد أنّ استهلاك الـ chunk الأخير المتدفّق يطابق usage غير المتدفّق. ينبغي أن يكونا متطابقين.
  • أخطاء وسط التدفّق تبدو كنهاية نظيفة. إذا أخطأ النموذج العلوي في منتصف الطريق، فإنّ gateway صحيحاً يكشفه بوصفه استثناءً في حلقتك، لا اقتطاعاً صامتاً. تحقّق دائماً من أنّك استلمت سبب الإنهاء (أو chunk الاستهلاك) قبل أن تعامل النص كمكتمل — فالتدفّق الذي يتوقّف فجأةً ليس كالتدفّق الذي انتهى.

الخلاصة

الـ streaming عبر نقطة نهاية متوافقة مع OpenAI هو أربعة أجزاء متحرّكة: stream=True، وتكرار الـ chunks، وقراءة كل delta، وترك الـ SDK يتولّى [DONE]. أضف stream_options={"include_usage": True} فتحصل أيضاً على أعداد توكنات صادقة على الـ chunk الأخير. الخمسة عشر سطراً ذاتها تعمل دون تغيير عبر Claude Sonnet 4.6 وGemini 2.5 Flash وعائلة GPT خلف base_url واحد — بدّل سلسلة النموذج، وأبقِ الحلقة.

قبل أن تطلق، قِس زمن أول توكن وقارن استهلاك المتدفّق مقابل غير المتدفّق. التدفّق الحقيقي يمنحك TTFB دون الثانية وأعداداً متطابقة؛ أما الإعادة المخزَّنة فتفضح زمنها. على Brievio، النداءات الفاشلة 4xx/5xx لا تُحاسَب، فيمكنك إجراء هذه الفحوص مجاناً. راجع مرجع Chat Completions للائحة المعاملات الكاملة، وبقية وثائق الـ API للأدوات والرؤية عبر التدفّق ذاته، و دليل نداء Claude بـ OpenAI SDK لأساسيات النداء غير المتدفّق، و كتالوج النماذج لكل slug يمكنك توجيه هذه الحلقة إليه.