العودة إلى المدونة

استكشاف الأخطاءدليل

json.JSONDecodeError على رد LLM: استخرج الـ JSON في Python

هل يفشل json.loads على رد نموذج؟ اقرأ exc.pos و finish_reason لمعرفة السبب، ثم استخرج الـ JSON بـ raw_decode وأعد المحاولة مرة واحدة.

بواسطة CheapTokens Teamنُشر في 6 دقيقة قراءة

رد JSON مسوّر بجانب JSONDecodeError عند الموضع 0
في هذه الصفحة
  1. ثلاث طرق يفسد بها الرد استدعاء json.loads
  2. الإجابات المقطوعة: تحقق من finish_reason قبل التحليل
  3. استخرج الـ JSON من النص
  4. أعد المحاولة مرة واحدة مع مطالبة أكثر صرامة
  5. أي عرض، أي إصلاح
  6. هل يمكن للـ API فرض JSON صالح؟

سكربتك يطلب من النموذج JSON، ويستدعي json.loads(reply) ثم يفشل بـ json.JSONDecodeError. عادةً يكون النموذج قد أجاب فعلًا: لكنه غلّف الـ JSON داخل ```json fence, put a line of chat in front of it, or ran into max_tokens halfway through. Look at exc.pos and finish_reason to tell which one it is, pull the object out with JSONDecoder.raw_decode، وأعد المحاولة مرة واحدة إذا لم يمكن إنقاذ أي شيء.

ثلاث طرق يفسد بها الرد استدعاء json.loads#

توثيق Python لـ json (توثيق Python 3.15، تم التحقق منه في 2 نوفمبر 2026) يقول إن JSONDecodeError فئة فرعية من ValueError لها الخصائص msg، doc، pos، lineno و colno. ما يهمنا هنا هو pos: الفهرس في النص حيث فشل التحليل.

لنفترض أن سكربتًا يستخرج حقولًا من رسائل بريد العملاء ويطلب {"name": ..., "age": ...}. هذه النصوص الثلاثة هي الردود التي تكسره. الصقها في صدفة Python:

python
import json

fenced = '```json\n{"name": "Anna", "age": 31}\n```'
chatty = 'Sure! Here is the JSON:\n{"name": "Anna", "age": 31}'
cut = '{"name": "Anna", "age": 31'

for reply in (fenced, chatty, cut):
    try:
        json.loads(reply)
    except json.JSONDecodeError as exc:
        print(exc.pos, len(reply))

يفشل الأولان عند pos 0: نظر المحلل إلى أول حرف تمامًا، علامة backtick أو S، واستسلم. يفشل الثالث عند نهاية النص (pos يساوي len(reply)): كل ما قرأه كان سليمًا، ثم نفد النص. هذا الفرق هو تشخيصك. الموضع 0 يعني أن الـ JSON موجود على الأرجح، لكنه مغلّف فقط. الموضع عند النهاية يعني أن الإجابة توقفت مبكرًا.

مسار القرار لرد فاشل: تحقق من finish_reason، استخرج بـ raw_decode، أعد المحاولة مرة واحدة
ترتيب الفحوصات المستخدم في هذه المقالة

الإجابات المقطوعة: تحقق من finish_reason قبل التحليل#

الرد الذي يتوقف في المنتصف ليس مشكلة تحليل، بل مشكلة طول. تخبرك الـ API: الخيار له finish_reason، و "length" يعني أن الإجابة قُطعت بواسطة max_tokens (القصة الكاملة في هل قُطعت إجابة الذكاء الاصطناعي؟ أصلح finish_reason "length" و max_tokens). انظر إليه أولًا، قبل أي استخراج.

هناك فخ خبيث إذا تخطيت تلك الخطوة. الماسح الذي يبحث عن أول { قابل للتحليل يمكن أن يلتقط كائنًا داخليًا مكتملًا من كائن خارجي نصف منتهٍ، ويستمر سكربتك بالبيانات الخاطئة دون خطأ. جرّب:

جرّب بنفسكجرّب: ماذا تُرجع دالة "ابحث عن أول كائن" لهذا الرد المقطوع؟إظهار الإجابة

خذ '{"user": {"name": "Anna"}, "age": 31'. الكائن الخارجي لا يُغلق أبدًا، لكن الداخلي {"name": "Anna"} مكتمل، لذا يُرجع المستخرج أدناه {'name': 'Anna'}. لا استثناء، نتيجة خاطئة. لهذا يأتي فحص finish_reason قبل الاستخراج.

إصلاح القطع الحقيقي هو مساحة أكبر: ارفع max_tokens. يسمح DeepSeek-V4-Flash بما يصل إلى 32,768 رمز إخراج لكل إجابة، و GLM-5.3-Flash حتى 8,192، لذا قائمة JSON طويلة لديها مساحة أكبر على الأول. تفاصيل النماذج على صفحة نماذج CheapTokens.

استخرج الـ JSON من النص#

بالنسبة للأسوار والثرثرة، المكتبة القياسية لديها الأداة بالفعل. وفقًا لنفس صفحة Python، JSONDecoder.raw_decode(s) يفك ترميز مستند JSON من نص يبدأ بواحد ويُرجع الكائن بالإضافة إلى الفهرس حيث انتهى المستند. تقول الوثائق إنه يمكن استخدامه على نص ببيانات زائدة في النهاية. يحتاج المستند أن يبدأ عند أول حرف، لذا اقطع النص البادئ أولًا: ابحث عن {، حاول فك الترميز من هناك، وانتقل إلى { التالي إذا فشل ذلك.

python
import json

def extract_json(text):
    """Return the first JSON object found in text, or raise ValueError."""
    decoder = json.JSONDecoder()
    for i, ch in enumerate(text):
        if ch == '{':
            try:
                obj, _end = decoder.raw_decode(text[i:])
                return obj
            except json.JSONDecodeError:
                continue
    raise ValueError('no JSON object in the reply')

يتعامل مع السور (البادئ ```json is skipped because it is not a {), the sentence before the object, and text after the closing brace. Expecting a list? Look for '[' instead. Don't look for both: a stray [1] في جملة مثل "انظر الملاحظة [1]" هو JSON صالح وسيفوز.

هناك شيئان لا يفعلهما عمدًا. لا يعدّل النص أبدًا (لا regex يحذف backticks، لا تخمين للأقواس المفقودة)، لذا لا يمكنك تحويل رد مكسور إلى بيانات صالحة لكن خاطئة. ويفشل بصوت عالٍ عندما لا يوجد شيء للعثور عليه، وهذا ما تحتاجه إعادة المحاولة أدناه.

قبل: json.loads على رد مسوّر. بعد: extract_json يُرجع الكائن
نفس الرد المسوّر قبل وبعد extract_json

أعد المحاولة مرة واحدة مع مطالبة أكثر صرامة#

إذا قُطع الرد أو لم يحتوي على أي كائن على الإطلاق، اسأل مرة أخرى. مرة واحدة، ليس في حلقة لا نهائية. عند القطع، أعطِ المحاولة الثانية مساحة أكبر؛ وإلا قل بصراحة ما تريد. هذا يستخدم OpenAI Python SDK مقابل عنوان CheapTokens الأساسي (https://api.cheaptokens.dev/v1) ومعرّف النموذج الكامل من الوثائق؛ المفتاح موجود في متغير بيئة.

python
import os
from openai import OpenAI

client = OpenAI(
    base_url='https://api.cheaptokens.dev/v1',
    api_key=os.environ['CHEAPTOKENS_API_KEY'],
)
MODEL = 'deepseek-ai/DeepSeek-V4-Flash-0731'
STRICT = '\n\nReply with one JSON object only. No text before or after it, no code fence.'

def ask_json(prompt, max_tokens=800):
    content = prompt
    for attempt in (1, 2):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=[{'role': 'user', 'content': content}],
            max_tokens=max_tokens,
        )
        choice = resp.choices[0]
        if choice.finish_reason == 'length':
            max_tokens *= 2          # cut off: more room next time
        else:
            try:
                return extract_json(choice.message.content or '')
            except ValueError:
                pass                 # no object: try the stricter prompt
        content = prompt + STRICT
    raise ValueError('no valid JSON after one retry')

الترتيب هو النقطة: finish_reason أولًا، الاستخراج ثانيًا، وبعد ذلك فقط إعادة المحاولة. ارفع الخطأ بعد المحاولة الثانية حتى تتمكن مهمة دفعية من تسجيل ذلك العنصر والمضي قدمًا.

تكلفة إعادة المحاولة سهلة التقدير. افترض طلبًا بـ 1,200 رمز إدخال و 300 رمز إخراج (افتراض، استخدم أرقامك الخاصة): $0.000048 على DeepSeek-V4-Flash أو $0.000036 على GLM-5.3-Flash. إذا احتاج 30 من 1,000 طلب إلى المحاولة الثانية، فالفترة الإضافية هي 30 × ذلك الطلب، $0.0014 على DeepSeek-V4-Flash. الأسعار حية، تأتي من قائمة الأسعار الحالية.

رتّب بالترتيب

رتّب خطوات الإصلاح

  1. تحقق من finish_reason: توقف هنا إذا قال length
  2. ابحث عن أول { واقطع النص قبله
  3. فك الترميز بـ raw_decode وتجاهل ما يلي
  4. إذا لم يُعثر على شيء، أعد المحاولة مرة واحدة مع المطالبة الأكثر صرامة

أي عرض، أي إصلاح#

الخطوات أعلاه تبقى في رأسك حتى يظهر الخطأ الغريب التالي. هذا هو نفس الشيء كجدول بحث: ابحث عما تراه على الشاشة واقرأ الإصلاح.

اختر حالتك

اختر ما تراه

يفشل json.loads عند الموضع 0

يبدأ الرد بسور أو جملة. مرّره عبر extract_json؛ لا حاجة لإعادة المحاولة.

الموضع عند نهاية النص

توقفت الإجابة مبكرًا. تحقق من finish_reason: إذا كان length، ارفع max_tokens واسأل مرة أخرى.

الخطأ في مكان ما في المنتصف

كتب النموذج JSON مكسورًا بنفسه (فاصلة مفقودة، فاصلة زائدة، علامة اقتباس غير مهربة). لا ترقّعه: أعد المحاولة مرة واحدة مع المطالبة الأكثر صرامة وسجّل الرد إذا فشل مرة أخرى.

يعمل json.loads لكن مفتاح مفقود

التحليل سليم، المحتوى ليس كذلك. اذكر المفاتيح الدقيقة في المطالبة وتحقق منها في الكود بعد التحليل.

هل يمكن للـ API فرض JSON صالح؟#

أحيانًا. يوثق دليل المخرجات المهيكلة من OpenAI وضعين: وضع JSON (type: json_object)، الذي يُرجع JSON صالحًا لكنه لا يتبع مخططًا، والمخرجات المهيكلة (type: json_schema، strict: true)، التي تتبع؛ توصي OpenAI بالثاني حيثما أمكن (المصدر: developers.openai.com، تم التحقق منه في 2 نوفمبر 2026). تلك خيارات API الخاصة بـ OpenAI. قبل الاعتماد على response_format مع نقطة نهاية أخرى، تحقق من وثائق CheapTokens؛ الاستخراج وإعادة المحاولة أعلاه يعملان مع إكمالات الدردشة العادية ولا يحتاجان معاملًا خاصًا. المزيد عن خيارات جانب الطلب في احصل على JSON صالح من API LLM: response_format وإعادة المحاولات.

قبل أن تشحن المحلل، تحقق من أربعة أشياء: finish_reason يُقرأ قبل تحليل النص، هناك إعادة محاولة واحدة فقط (والفشل الثاني يُسجّل مع معرّف العنصر)، يتحقق الكود من وجود المفاتيح المتوقعة بعد التحليل، و max_tokens يترك مساحة لأطول JSON تتوقعه.

في المرة القادمة التي يرمي فيها json.loads، اطبع exc.pos و finish_reason أولًا. هذان الرقمان يخبرانك عادةً بما حدث قبل أن تقرأ سطرًا من الرد.