DeepSeek Harness: بروتوكول StreamChunk ومعالجة الأخطاء
آخر تحديث: 2026-08-31
المخرجات المتدفقة هي تجربة تفاعل الوكيل الأساسية — المستخدمون لا يريدون الانتظار 30 ثانية للحصول على رد كامل، يريدون مشاهدة الوكيل يفكر كلمة بكلمة. بروتوكول StreamChunk هو تجريد DSH الموحد للمخرجات المتدفقة، ومعالجة الأخطاء تضمن التدهور السليم عند حدوث استثناءات.
📋 المتطلبات المسبقة: أكمل 24-llm-adapter.md، تفهم محولات LLM
1. ما ستتعلمه
- بروتوكول المخرجات المتدفقة: StreamChunk
- أنواع القطع: text/tool_call/tool_result
- آليات الاسترداد من الأخطاء
- المقاطعة والإلغاء
- استراتيجيات إعادة المحاولة
- التسجيل والقابلية للملاحظة
2. تفاصيل بروتوكول StreamChunk
(1) ▶ مثال 1
type StreamChunk =
| TextChunk
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
| DoneChunk
interface TextChunk {
type: 'text'
content: string
}
interface ToolCallChunk {
type: 'tool_call'
id: string
name: string
arguments: string
index: number
}
interface ToolResultChunk {
type: 'tool_result'
id: string
toolCallId: string
result: any
isError: boolean
}
interface ErrorChunk {
type: 'error'
error: Error
recoverable: boolean
retryAfter?: number
}
interface DoneChunk {
type: 'done'
reason: 'stop' | 'tool_use' | 'length' | 'cancel' | 'error'
usage?: TokenUsage
}
interface TokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
}
(2) ▶ مثال 2
graph LR
START[بدء التدفق] --> TEXT[TextChunk × N]
TEXT --> TC[ToolCallChunk]
TC --> TR[ToolResultChunk]
TR --> TEXT2[TextChunk × N]
TEXT2 --> DONE[DoneChunk]
تدفق محادثة وكيل نموذجي:
- LLM يُخرج نصاً → TextChunk
- LLM يقرر استدعاء أداة → ToolCallChunk
- تنفيذ الأداة يكتمل → ToolResultChunk
- LLM يُكمل الإخراج → TextChunk
- التدفق ينتهي → DoneChunk
▶ مثال 3
const stream = ctx.llm.stream({
messages: [{ role: 'user', content: 'list project files' }],
tools: availableTools
})
for await (const chunk of stream) {
switch (chunk.type) {
case 'text':
process.stdout.write(chunk.content)
break
case 'tool_call':
console.log(`\n🔧 Tool call: ${chunk.name}`)
console.log(` Arguments: ${chunk.arguments}`)
break
case 'tool_result':
if (chunk.isError) {
console.log(` ❌ Tool error: ${chunk.result}`)
} else {
console.log(` ✅ Tool result: ${JSON.stringify(chunk.result)}`)
}
break
case 'error':
console.error(`\n⚠️ Error: ${chunk.error.message}`)
if (chunk.recoverable) {
console.log(` Will retry in ${chunk.retryAfter}ms`)
}
break
case 'done':
console.log(`\n✅ Done (${chunk.reason})`)
if (chunk.usage) {
console.log(` Token usage: ${chunk.usage.totalTokens}`)
}
break
}
}
3. أنواع القطع بالتفصيل
(1) TextChunk
أجزاء المحتوى النصي، تُخرج تدريجياً:
// LLM يُخرج "Hello, world!"
// قد يُنتج عدة TextChunks:
// قطعة 1: { type: 'text', content: 'Hello' }
// قطعة 2: { type: 'text', content: ', ' }
// قطعة 3: { type: 'text', content: 'world' }
// قطعة 4: { type: 'text', content: '!' }
المستهلكون يجب أن يربطوا جميع TextChunks بدلاً من عرضها فرادى.
(2) ToolCallChunk
LLM يطلب استدعاء أداة:
{
type: 'tool_call',
id: 'call_abc123',
name: 'file_edit',
arguments: '{"action":"read","path":"src/index.ts"}',
index: 0
}
ملاحظة: arguments هي سلسلة JSON تحتاج تحليلاً.
(3) ToolResultChunk
النتائج بعد تنفيذ الأداة:
{
type: 'tool_result',
id: 'result_xyz789',
toolCallId: 'call_abc123',
result: { content: 'export const name = ...' },
isError: false
}
isError: true يشير لفشل تنفيذ الأداة؛ النتيجة تحتوي معلومات الخطأ.
(4) استدعاءات أدوات متعددة
محادثة واحدة قد تستدعي أدوات متعددة:
TextChunk: "Let me check two files"
ToolCallChunk: { name: "file_edit", id: "call_1", index: 0 }
ToolCallChunk: { name: "file_edit", id: "call_2", index: 1 }
ToolResultChunk: { toolCallId: "call_1", result: ... }
ToolResultChunk: { toolCallId: "call_2", result: ... }
TextChunk: "The contents of both files above..."
DoneChunk: { reason: "stop" }
4. آلية الاسترداد من الأخطاء
(1) تصنيف الأخطاء
// خطأ غير قابل للاسترداد (مثلاً مفتاح API غير صالح)
{
type: 'error',
error: new Error('Invalid API key'),
recoverable: false
}
// خطأ قابل للاسترداد (مثلاً حد المعدل)
{
type: 'error',
error: new Error('Rate limit exceeded'),
recoverable: true,
retryAfter: 5000
}
// خطأ قابل للاسترداد (مثلاً تقلب الشبكة)
{
type: 'error',
error: new Error('Connection timeout'),
recoverable: true,
retryAfter: 2000
}
(2) تدفق الاسترداد التلقائي
graph TD
ERR[ErrorChunk] --> CHECK{recoverable؟}
CHECK -->|لا| FAIL[إنهاء التدفق + DoneChunk<br/>reason: error]
CHECK -->|نعم| WAIT[انتظار retryAfter]
WAIT --> RETRY[إعادة محاولة الطلب]
RETRY --> SUCCESS{نجاح؟}
SUCCESS -->|نعم| CONTINUE[استمرار التدفق]
SUCCESS -->|لا| ERR2[ErrorChunk آخر]
ERR2 --> CHECK2{محاولات مستنفدة؟}
CHECK2 -->|لا| WAIT
CHECK2 -->|نعم| FAIL
(3) الاسترداد اليدوي
for await (const chunk of stream) {
if (chunk.type === 'error') {
if (chunk.recoverable) {
ctx.logger.warn(`Stream error, recoverable: ${chunk.error.message}`)
// الإطار يُعيد المحاولة تلقائياً
} else {
ctx.logger.error(`Stream error, non-recoverable: ${chunk.error.message}`)
break
}
}
}
(4) معالجة أخطاء الأدوات
عند فشل تنفيذ أداة، الإطار يُعيد الخطأ كـ ToolResultChunk لـ LLM:
{
type: 'tool_result',
id: 'result_1',
toolCallId: 'call_1',
result: { error: true, message: 'Permission denied: /etc/passwd' },
isError: true
}
LLM يمكنه بعدها اختيار:
- إعادة المحاولة بمسار مختلف
- إبلاغ المستخدم بصلاحيات غير كافية
- إكمال المهمة بطريقة أخرى
5. المقاطعة والإلغاء
(1) إلغاء المستخدم
عندما ينقر المستخدم زر "إيقاف" في واجهة الويب، يُلغى التدفق:
const controller = new AbortController()
const stream = ctx.llm.stream(request, { signal: controller.signal })
// المستخدم يُلغي
controller.abort()
// التدفق يُنتج DoneChunk
// { type: 'done', reason: 'cancel' }
(2) إلغاء المهلة
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 60000)
try {
for await (const chunk of ctx.llm.stream(request, { signal: controller.signal })) {
// معالجة القطعة
}
} finally {
clearTimeout(timer)
}
(3) إلغاء شرطي
let tokenCount = 0
for await (const chunk of stream) {
if (chunk.type === 'text') {
tokenCount += chunk.content.length
if (tokenCount > 10000) {
controller.abort()
break
}
}
}
(4) تنظيف الإلغاء
بعد الإلغاء، الإطار يضمن:
- التوقف عن استقبال قطع جديدة
- القطع المُستقبلة مسبقاً تُعالج بشكل طبيعي
- إنتاج
DoneChunk { reason: 'cancel' } - تحرير اتصالات الشبكة
6. استراتيجيات إعادة المحاولة
(1) استراتيجيات إعادة المحاولة المدمجة
| نوع الخطأ | المحاولات | استراتيجية التراجع |
|---|---|---|
| حد المعدل (429) | 3 | تراجع أسي |
| مهلة الشبكة | 2 | فاصل ثابت |
| خطأ الخادم (5xx) | 2 | تراجع أسي |
| خطأ المصادقة (401) | 0 | بدون إعادة محاولة |
| خطأ الطلب (400) | 0 | بدون إعادة محاولة |
(2) إعداد إعادة المحاولة
export const Config = Schema.object({
maxRetries: Schema.number().default(3).description('Maximum retry attempts'),
retryDelay: Schema.number().default(1000).description('Initial retry delay (ms)'),
retryMultiplier: Schema.number().default(2).description('Delay multiplier for exponential backoff')
})
(3) منطق إعادة محاولة مخصص
async function streamWithRetry(
ctx: Context,
request: LLMRequest,
maxRetries = 3
): Promise<void> {
let attempt = 0
while (attempt <= maxRetries) {
try {
for await (const chunk of ctx.llm.stream(request)) {
if (chunk.type === 'error' && chunk.recoverable) {
attempt++
const delay = Math.min(1000 * Math.pow(2, attempt), 30000)
ctx.logger.warn(`retry ${attempt}/${maxRetries} in ${delay}ms`)
await sleep(delay)
break // إعادة الدخول لحلقة while
}
// معالجة القطع الطبيعية
}
return // نجاح
} catch (error) {
attempt++
if (attempt > maxRetries) throw error
}
}
}
7. التسجيل والقابلية للملاحظة
(1) تسجيل التدفق
ctx.on('llm/stream/start', (request) => {
ctx.logger.info(`stream started: model=${request.model}`)
})
ctx.on('llm/stream/chunk', (chunk) => {
ctx.logger.debug(`chunk: type=${chunk.type}`)
})
ctx.on('llm/stream/end', (done) => {
ctx.logger.info(`stream ended: reason=${done.reason}, tokens=${done.usage?.totalTokens}`)
})
(2) مقاييس الأداء
interface StreamMetrics {
ttfb: number // Time to First Byte
totalDuration: number // المدة الإجمالية
chunkCount: number // عدّاد القطع
toolCallCount: number // عدّاد استدعاءات الأدوات
retryCount: number // عدّاد إعادة المحاولة
tokenUsage: TokenUsage // استخدام الرموز
}
(3) التسجيل المهيكل
ctx.logger.info('llm_request', {
model: request.model,
messageCount: request.messages.length,
toolCount: request.tools?.length || 0,
stream: true
})
ctx.logger.info('llm_response', {
model: response.model,
duration: Date.now() - startTime,
tokens: response.usage?.totalTokens
})
❓ أسئلة شائعة
typescript const start = Date.now() for await (const chunk of stream) { if (chunk.type === 'text') { const ttfb = Date.now() - start ctx.logger.info(`TTFB: ${ttfb}ms`) break } } complete() و stream(). المستدعون يختارون حسب الحاجة.typescript async function* mockStream(): AsyncIterable<StreamChunk> { yield { type: 'text', content: 'Hello' } yield { type: 'text', content: ', world!' } yield { type: 'done', reason: 'stop' } } 📖 ملخص
- StreamChunk خمسة أنواع: text، tool_call، tool_result، error، done
- دورة حياة التدفق: إخراج نص → استدعاء أداة → نتيجة أداة → استمرار الإخراج → انتهاء
- الأخطاء مصنفة كقابلة للاسترداد وغير قابلة؛ القابلة للاسترداد تُعاد محاولتها تلقائياً
- الإلغاء عبر AbortController يُنتج
DoneChunk { reason: 'cancel' } - استراتيجيات إعادة المحاولة تختلف حسب نوع الخطأ؛ التراجع الأسي هو الافتراضي
- التسجيل والقابلية للملاحظة عبر أحداث دورة الحياة والتسجيل المهيكل
📝 تمارين
1. ⭐ أساسي: اكتب مستهلكاً يتكرر على تدفق StreamChunk، يعدّ قطع text/tool_call/tool_result/error/done منفصلة. اختبر بتدفق محاكاة.
2. ⭐⭐ متوسط: نفّذ مستهلك تدفق بمهلة — إذا لم تصل قطعة خلال 30 ثانية، ألغِ التدفق تلقائياً. استخدم AbortController للإلغاء.
3. ⭐⭐⭐ تحدٍ: نفّذ غلاف إعادة محاولة مخصص حول ctx.llm.stream() يُعيد المحاولة تلقائياً عند أخطاء قابلة للاسترداد (تراجع أسي، 3 محاولات كحد أقصى). سجّل تأخير ونتيجة كل محاولة. اختبر بمحول LLM محاكاة يتناوب بين الفشل والنجاح.