Asosiy qismga o'tish

JSON schema dan grammar ga: ixtiyoriy kalit tuzog'i

9-bob 9 kod bloki 2197 so'z

Yetti soatlik xato

3-bobda grammar ni "birinchi to'siq" deb atadik: model faqat grammar ruxsat bergan tokenlarni yoza oladi. 8-bobda toollar va ularning variantlarini ko'rdik. Bu bob — shu ikkisi uchrashgan joyda yuz bergan eng qimmat xato haqida.

tez9 modeli yetti soat o'qitildi. Birinchi test natijasi — eski modeldan ancha yomon. Birinchi xulosa: "tez9 yomon o'qitilgan". Bu xulosa noto'g'ri edi. Model o'z ishini to'g'ri o'rgangan edi — unga yozishga ruxsat berilmagan edi.

Buni tushunish uchun grammar qanday yasalishini ko'rishimiz kerak. Va buni taxmin bilan emas, llama.cpp ning o'z kodi bilan qilamiz.

llama.cpp ning o'z kodi bilan

Model serveri JSON schema ni olib, uni grammar ga aylantiradi. Bu ishni llama.cpp dagi bitta funksiya qiladi: json_schema_to_grammar. U serverning o'zi ishlatadigan kutubxonada turibdi (libllama-common.so). Shu funksiyani chaqiradigan o'n qatorli dastur yozamiz — model ham, server ham kerak emas:

shu faylni yaratinggrammar.cpp

python8 qator
#include "json-schema-to-grammar.h"
#include <iostream>
#include <iterator>

int main() {
    std::string text((std::istreambuf_iterator<char>(std::cin)), std::istreambuf_iterator<char>());
    std::cout << json_schema_to_grammar(common_json::parse(text));
}

Uni kompilyatsiya qilamiz. Uchta sarlavha fayli llama.cpp ning b11946 versiyasidan olingan va kitob papkasida saqlangan (datasets/llama_b11946); kutubxona — Tez benchmarklari ishlatgan llama.cpp ning o'zi:

python3 qator
LIB=/mnt/d/Tez_spikes/llama-b11946-linux-cpu
g++ -std=c++17 -O1 -I../datasets/llama_b11946 grammar.cpp -L$LIB -lllama-common -Wl,-rpath,$LIB -o grammar
echo '{"type": "object", "properties": {"b": {"type": "string"}, "a": {"type": "string"}}, "required": ["a"]}' | ./grammar
Chiqish (qaytish kodi 0, 2271 ms)
a-kv ::= "\"a\"" space ":" space string
b-kv ::= "\"b\"" space ":" space string
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
root ::= "{" space a-kv ( "," space ( b-kv ) )? space "}"
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""

Bu — GBNF formatidagi grammar: har bir qator bitta qoida. Oxiridagi root qatoriga qarang. Schema da kalitlar b, keyin a tartibida yozilgan. Lekin grammar boshqacha tartib chiqardi: avval a (u majburiy, required), keyin b (ixtiyoriy, qavs ichida va ? bilan).

Ya'ni: llama.cpp majburiy kalitlarni oldinga, ixtiyoriylarini orqaga qo'yadi — schema da qanday yozilganidan qat'iy nazar. Bu grammar bilan {"b": ..., "a": ...} deb yozib bo'lmaydi. Model { dan keyin faqat "a" ni yoza oladi.

Bir qarashda bu kichik narsa. JSON da kalitlar tartibi ahamiyatsiz-ku?

tez9 da nima bo'ldi

JSON uchun — ahamiyatsiz. Model uchun — juda muhim.

tez9 track ida Tezga yangi imkoniyat qo'shildi: butun jadval bo'yicha bitta jami ("jami kredit qancha?"). Buning uchun group_and_total ning group_by argumenti ixtiyoriy qilindi: bo'lsa — guruhlab jamlaydi, bo'lmasa — bitta jami beradi. Mantiqan to'g'ri o'zgarish.

Lekin u schema ni qanday o'zgartirganini ko'raylik. O'sha paytdagi shaklni qo'lda yozamiz:

python16 qator
import json
import subprocess

def grammar(schema):
    return subprocess.run([str(ISH / "grammar")], input=json.dumps(schema), capture_output=True, text=True, check=True).stdout

tez9_shakli = {
    "type": "object",
    "properties": {
        "group_by": {"type": "array", "items": {"type": "string"}},
        "value_column": {"type": "string"},
        "agg": {"enum": ["sum", "count", "avg"]},
    },
    "required": ["value_column", "agg"],
}
print(next(q for q in grammar(tez9_shakli).splitlines() if q.startswith("root")))
Chiqish
root ::= "{" space value-column-kv "," space agg-kv ( "," space ( group-by-kv ) )? space "}"

group_by endi faqat oxirida kelishi mumkin. Model esa nimani o'rgangan? Training ma'lumotidagi har bir group_and_total javobida kalitlar qaysi tartibda turganini sanaymiz — tez9 ning o'z training faylidan:

python9 qator
from collections import Counter

birinchi_kalit = Counter()
for qator in open(Path.home() / "tez_runs/tez9/data/train.jsonl", encoding="utf-8"):
    javob = json.loads(json.loads(qator)["messages"][-1]["content"])
    for qadam in javob.get("steps", []):
        if qadam["tool"] == "group_and_total" and "group_by" in qadam["args"]:
            birinchi_kalit[next(iter(qadam["args"]))] += 1
print("group_by bor javoblarda birinchi kalit:", dict(birinchi_kalit))
Chiqish
group_by bor javoblarda birinchi kalit: {'group_by': 4977}

Model minglab marta {"group_by": ... deb boshlashni o'rgangan. Endi { dan keyin u "group_by" yozmoqchi — grammar esa faqat "value_column" ga ruxsat beradi. Model majburan value_column yozadi, keyin agg — va endi u o'zi hech qachon ko'rmagan holatda. Oxirida group_by ni qo'shish imkoni bor, lekin model buni o'rganmagan. Natija: guruhlash tushib qoladi, va "filiallar bo'yicha jami" o'rniga bitta umumiy jami chiqadi.

O'lchov

Bu taxminni saqlangan natijalar bilan tekshiramiz. Eski grammar bilan tez9 faqat 11 ta test to'plamida ishga tushirilgan edi. Shu savollarda tez8 va tez9 ni solishtiramiz, va tez9 ning yangi xatolari ichida "kutilgan javobda guruhlash bor, model yozgan javobda yo'q" holatlarini sanaymiz:

python20 qator
import glob

toplamlar = sorted(Path(f).stem.split("_cpu_")[1] for f in glob.glob(str(TEZ / "results/tez9_cpu_*.jsonl")))
jami = tez8_togri = tez9_togri = yangi_xato = guruh_tushgan = 0
for toplam in toplamlar:
    savollar = {s["id"]: s for s in json.load(open(TEZ / f"accuracy/suites/{toplam}/questions.json", encoding="utf-8"))}
    t8 = {j["id"]: j for j in map(json.loads, open(TEZ / f"results/tez8_cpu_{toplam}.jsonl", encoding="utf-8"))}
    t9 = {j["id"]: j for j in map(json.loads, open(TEZ / f"results/tez9_cpu_{toplam}.jsonl", encoding="utf-8"))}
    for i, javob in t9.items():
        jami += 1
        tez8_togri += t8[i]["correct"]
        tez9_togri += javob["correct"]
        if t8[i]["correct"] and not javob["correct"]:
            yangi_xato += 1
            kutilgan = json.dumps(savollar[i]["expect"])
            if ('"group_by"' in kutilgan or '"group"' in kutilgan) and not ('"group_by"' in javob["output"] or '"group"' in javob["output"]):
                guruh_tushgan += 1

print(f"{len(toplamlar)} to'plam, {jami} savol: tez8 {tez8_togri}, tez9 (eski grammar) {tez9_togri}")
print(f"tez9 ning yangi xatolari: {yangi_xato}, shundan guruhlash tushib qolgani: {guruh_tushgan}")
Chiqish
11 to'plam, 480 savol: tez8 472, tez9 (eski grammar) 446
tez9 ning yangi xatolari: 28, shundan guruhlash tushib qolgani: 20

Yangi xatolarning katta qismi — aynan guruhlash tushib qolgan javoblar. Model yomon o'qitilmagan edi. Grammar unga o'rgangan tartibda yozishga ruxsat bermagan edi.

Ikkinchi xato: noto'g'ri solishtirish

Birinchi xulosa nega "tez9 yomon" bo'lgan? Chunki tez9 ning yangi grammar bilan olingan natijalari tez8 ning eski grammar bilan olingan natijalari bilan solishtirilgan edi. Ikki narsa bir paytda o'zgargan — model ham, grammar ham. Xatolar jurnalida bu ikki alohida qator (1 va 2), va ulardan ikkita qoida chiqdi: schema yoki grammar o'zgarsa — haqiqiy grammar ni yasab, kalit tartibini train qilish ma'lumoti bilan solishtir; modellarni solishtirganda — bir xil grammar, bir xil savollar, grammar o'zgargan bo'lsa ikkala modelni qayta ishga tushir.

Bu jurnalda tez9 ning yangi xatolaridan "taxminan 24 tasi" guruhlash deb yozilgan. Yuqoridagi kod aniq qoida bilan qayta sanaydi, va uning soni boshqacha bo'lishi mumkin: jurnaldagi son o'sha kuni qo'lda ko'rib chiqilgan edi. Kitobda o'lchangan son turadi.

Tuzatish: har bir kalit majburiy

Yechim — grammar ni "aldash" emas, schema ni boshqacha yasash. Agar kalit ixtiyoriy bo'lgani uchun oxirga surilsa, unda ixtiyoriy kalit bo'lmasin. Buning o'rniga har bir mumkin bo'lgan kalitlar to'plami uchun alohida variant yasaladi, va har bir variant ichida hamma kalit majburiy.

prompt/build.py dagi funksiya:

python4 qator
import inspect
from prompt import build

print(inspect.getsource(build._key_choices))
Chiqish
def _key_choices(args):
    optional = [name for name, spec in args.items() if spec.get("optional")]
    for kept in product((True, False), repeat=len(optional)):
        dropped = {name for name, keep in zip(optional, kept) if not keep}
        yield {name: spec for name, spec in args.items() if name not in dropped}

group_and_total da ikkita ixtiyoriy argument bor: group_by va share. Ikkalasi ham bor yoki yo'q bo'lishi mumkin — demak to'rtta kalitlar to'plami. Har biri alohida variant bo'ladi, va har birida hamma kalit required. Endi llama.cpp hech narsani surmaydi: hamma kalit majburiy bo'lsa, u schema dagi tartibni saqlaydi.

Hozirgi Tez schema sini 3-bobdagi kredit jadvali uchun yasab, group_and_total variantlarini ko'raylik va butun schema ni grammar ga aylantiramiz:

python14 qator
kontekst = build.load_context(TEZ / "prompt/domains/bank_loans")
schema = build.output_schema(kontekst)
variantlar = [v for v in schema["anyOf"][0]["properties"]["steps"]["items"]["anyOf"]
              if v["properties"]["tool"]["const"] == "group_and_total"]
for v in variantlar:
    args = v["properties"]["args"]
    print(f"kalitlar: {list(args['properties'])}  majburiy: {args['required'] == list(args['properties'])}")

qoidalar = grammar(schema).splitlines()
guruhli = next(q for q in qoidalar if q.endswith("-args-share-kv space \"}\"") and "group-by-kv" in q)
print()
print(f"schema: {len(json.dumps(schema)):,} belgi, grammar: {len(qoidalar)} qoida")
print("group_by li variantning qoidasi:")
print(guruhli.split(" ::= ")[1].replace(guruhli.split("-args")[0] + "-args-", ""))
Chiqish
kalitlar: ['group_by', 'value_column', 'agg', 'share']  majburiy: True
kalitlar: ['group_by', 'value_column', 'agg']  majburiy: True
kalitlar: ['value_column', 'agg', 'share']  majburiy: True
kalitlar: ['value_column', 'agg']  majburiy: True
kalitlar: ['group_by', 'value_column', 'agg', 'share']  majburiy: True
kalitlar: ['group_by', 'value_column', 'agg']  majburiy: True
kalitlar: ['value_column', 'agg', 'share']  majburiy: True
kalitlar: ['value_column', 'agg']  majburiy: True

schema: 48,781 belgi, grammar: 971 qoida
group_by li variantning qoidasi:
"{" space group-by-kv "," space value-column-kv "," space agg-kv "," space share-kv space "}"

Variantlar sakkizta: to'rtta kalitlar to'plami, har biri ikki xil (8-bobdagi sum/avg faqat sonli ustun bilan, count esa istalgan ustun bilan). Hammasida kalitlar majburiy. Va grammar qoidasida group_by endi birinchi turibdi — training ma'lumotidagi tartibda.

Narxi bor: variantlar soni ixtiyoriy argumentlar soni bilan ikki barobardan o'sadi. Shuning uchun toollarda ixtiyoriy argumentlar kam saqlanadi. Bu kredit jadvali uchun butun schema bir necha o'n ming belgi va mingga yaqin qoida bo'ldi. 6-bobdagi o'lchovda grammar ning vaqt narxi medianada 2 foiz atrofida edi.

Qoidani test bilan qotirish

Bu xato yana takrorlanmasligi kerak. Kimdir keyinroq yangi toolga ixtiyoriy argument qo'shsa, grammar yana tartibni buzishi mumkin. Shuning uchun Tezda test bor: har bir variant ichidagi hamma kalit majburiy bo'lishi shart. Uni kitob yig'ilayotganda ishga tushiramiz:

bash2 qator
cd /mnt/c/Users/Admin/Desktop/Offline/spikes/spike2_3_model
PYTHONDONTWRITEBYTECODE=1 "$OLDPWD/.venv/bin/python" -m pytest prompt/test_build.py -q -p no:cacheprovider -k "every_key_is_required" 2>&1 | tail -1
Chiqish (qaytish kodi 0, 6547 ms)
27 passed, 340 deselected in 2.97s

Tuzatish eski modelga zarar qildimi

Oxirgi savol: yangi grammar tez8 ni yomonlashtirmadimi? Yuqoridagi 11 ta to'plamda tez8 ning eski grammar bilan va yangi grammar bilan olingan natijalarini, hamda tez9 ning yangi grammar bilan natijasini solishtiramiz:

python11 qator
def ball(prefiks):
    togri = jami_savol = 0
    for toplam in toplamlar:
        javoblar = [json.loads(q) for q in open(TEZ / f"results/{prefiks}_cpu_{toplam}.jsonl", encoding="utf-8")]
        togri += sum(j["correct"] for j in javoblar)
        jami_savol += len(javoblar)
    return f"{togri}/{jami_savol}"

for prefiks, izoh in (("tez8", "tez8, eski grammar"), ("tez8g", "tez8, yangi grammar"),
                      ("tez9", "tez9, eski grammar"), ("tez9g", "tez9, yangi grammar")):
    print(f"{izoh:22s} {ball(prefiks)}")
Chiqish
tez8, eski grammar     511/520
tez8, yangi grammar    511/520
tez9, eski grammar     446/480
tez9, yangi grammar    506/520

tez8 — ikkala grammar da bir xil. tez9 esa yangi grammar bilan katta farq bilan tiklandi. Eski grammar da tez9 ning bitta to'plami to'liq ishga tushmagan (savollar soni kamroq) — shuning uchun uning maxraji boshqacha.

Lekin bu yerda halol bo'lish kerak: tiklangan tez9 ham tez8 dan yutmadi. To'liq testda (745 savol) tez9 — 698, tez8 — 707. Grammar tuzatilgach ham tez9 yutqazdi, va buning sabablari boshqa — 21-bob mavzusi.

Xulosa

  • llama.cpp JSON schema ni grammar ga aylantirganda majburiy kalitlarni oldinga, ixtiyoriylarini orqaga qo'yadi. Buni llama.cpp ning o'z funksiyasini chaqirib ko'rdik.
  • tez9 da group_by ixtiyoriy qilindi. Model group_by ni birinchi yozishni o'rgangan edi, grammar esa unga ruxsat bermadi — natijada guruhlash tushib qoldi.
  • Birinchi xulosa noto'g'ri edi, chunki ikki xil grammar dagi natijalar solishtirilgan.
  • Tuzatish: har bir kalitlar to'plami uchun alohida variant, ichida hamma kalit majburiy (_key_choices). Qoida test bilan qotirilgan.
  • Tuzatish tez8 ga zarar qilmadi. tez9 tiklandi, lekin baribir yutmadi.

Keyingi bobda plan bajarilgandan keyingi qismga o'tamiz: DuckDB engine va "bir xil natija" qanday tekshiriladi.

Bu bobni bepul o'qidingiz.

Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.

Telegramda yozish Narxlar