۱۳. پروژه (۲): خروجی ساختاریافته و اتصال به ذهنیار
مدل زبانی درس قبل هر چیزی میتواند بگوید. در این درس خروجیاش را مهار میکنیم — با فیلتر لاجیت و تعمیر خودکار JSON — و بعد آن را پشت همان «پریز استاندارد» فصل ۱ به ذهنیار وصل میکنیم.
چه میسازیم؟
| فایل | تازه یا تغییر؟ | کارش |
|---|---|---|
zehnyar/structured.py | تازه | نمونهبرداری محدودشده و تعمیر JSON |
projects/ch02_sampling/run_structured.py | تازه | اسکریپت اجرایی این درس |
zehnyar/providers/local_lm.py | تازه | مدل فصل ۲ پشت قرارداد Provider |
zehnyar/providers/__init__.py | تغییر | شناختن provider تازهٔ local |
zehnyar/config.py | تغییر | تنظیمات پیشفرض نمونهبرداری |
zehnyar/core/assistant.py | تغییر | رساندن دما و top-p به مدل |
قدم ۱ — خروجی محدودشده و تعمیر JSON
دو ابزار از ابزارهای درس ۱۰ را میسازیم. اولی نمونهبرداری محدودشده: در هر قدم، فقط توکنهایی اجازه دارند که هنوز به یکی از گزینههای مجاز میرسند. دومی پسپردازش دفاعی: سه اشتباه رایج JSON را خودکار درست میکند.
"""خروجی ساختاریافته — دو ابزار از ابزارهایی که در درس ۱۰ دیدیم.
۱. نمونهبرداری محدودشده (constrained sampling):
در هر قدم، لاجیت توکنهایی که به یک خروجی مجاز نمیرسند حذف میشوند.
مدل **نمیتواند** چیزی خارج از فهرست بگوید — نه اینکه «قول بدهد» نگوید.
۲. پسپردازش دفاعی (defensive parsing):
مدلها اشتباههای تکراری میکنند: آکولاد بسته را جا میاندازند، دور JSON
علامت ``` میگذارند، یا ویرگول اضافه آخر فهرست میگذارند. اینها را با
یک اسکریپت ساده درست میکنیم — همان کاری که لینکدین کرد و درصد خروجی
سالمش را از ۹۰ به ۹۹٫۹۹ رساند.
"""
import json
import math
import random
import re
from . import sampling
from .lm import NGramLM
def generate_choice(lm: NGramLM, prompt: str, options: list[str],
temperature: float = 1.0, seed: int | None = None) -> dict:
"""یکی از گزینههای مجاز را تولید میکند — توکن به توکن، با فیلتر لاجیت."""
rng = random.Random(seed)
encoded = {opt: lm.tok.encode(" " + opt.strip()) for opt in options}
context = lm.tok.encode(prompt)
made: list[int] = []
steps = []
while True:
# گزینههایی که هنوز با آنچه تا اینجا ساختهایم سازگارند
alive = [o for o, ids in encoded.items() if ids[:len(made)] == made]
done = [o for o in alive if len(encoded[o]) == len(made)]
if done:
choice = done[0]
break
allowed = {encoded[o][len(made)] for o in alive}
logits = lm.logits(context)
masked = {t: x for t, x in logits.items() if t in allowed}
before = sampling.softmax(logits, 1.0)
token, final = sampling.choose(masked, temperature, rng=rng)
steps.append({
"allowed": [lm.tok.vocab[t] for t in sorted(allowed)],
"picked": lm.tok.vocab[token],
"mass_kept": round(sum(before[t] for t in allowed), 6),
})
made.append(token)
context.append(token)
return {"choice": choice, "steps": steps}
def free_vs_constrained(lm: NGramLM, prompt: str, options: list[str],
temperature: float = 1.0, seed: int | None = None) -> dict:
"""یک بار آزاد تولید میکند، یک بار محدود — برای مقایسه در آزمایشگاه."""
free = lm.generate(prompt, max_tokens=8, temperature=temperature, seed=seed)
limited = generate_choice(lm, prompt, options, temperature, seed)
scores = {}
for o in options:
lp, n = lm.logprob(prompt, " " + o.strip())
scores[o] = round(lp / max(n, 1), 4) # میانگین logprob، نه جمع
return {
"free": free.text,
"free_valid": free.text.strip() in [o.strip() for o in options],
"constrained": limited["choice"],
"steps": limited["steps"],
"avg_logprob": scores,
"prob_share": _share(scores),
}
def _share(avg_logprobs: dict) -> dict:
exps = {k: math.exp(v) for k, v in avg_logprobs.items()}
total = sum(exps.values())
return {k: round(v / total, 4) for k, v in exps.items()}
# ---------------------------------------------------------------------------
# پسپردازش دفاعی
# ---------------------------------------------------------------------------
def parse_json_defensive(text: str):
"""JSON نیمهخراب را تا جای ممکن تعمیر میکند. خروجی: (شیء یا None، فهرست تعمیرها)."""
fixes = []
s = text.strip()
# ۱. حصار کد: ```json ... ```
fence = re.search(r"```(?:json)?\s*(.*?)(?:```|$)", s, re.S)
if fence:
s = fence.group(1).strip()
fixes.append("حذف حصار ```")
# ۲. متن اضافهٔ قبل از اولین آکولاد: «حتماً! این هم JSON شما: {...}»
start = min([i for i in (s.find("{"), s.find("[")) if i >= 0], default=-1)
if start > 0:
s = s[start:]
fixes.append("حذف متن اضافهٔ اول")
try:
return json.loads(s), fixes
except json.JSONDecodeError:
pass
# ۳. ویرگول آویزان: {"a": 1,}
s2 = re.sub(r",\s*([}\]])", r"\1", s)
if s2 != s:
s = s2
fixes.append("حذف ویرگول آویزان")
# ۴. رشته یا آکولاد بستهنشده (معمولاً چون تولید زود متوقف شده)
stack, in_str, esc = [], False, False
for ch in s:
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch in "{[":
stack.append("}" if ch == "{" else "]")
elif ch in "}]" and stack:
stack.pop()
if in_str:
s += '"'
fixes.append("بستن رشتهٔ نیمهکاره")
if stack:
s = re.sub(r",\s*$", "", s) + "".join(reversed(stack))
fixes.append("بستن " + str(len(stack)) + " آکولاد/کروشه")
try:
return json.loads(s), fixes
except json.JSONDecodeError:
return None, fixes
| این تکه از کد | یعنی چه |
|---|---|
encoded = {opt: lm.tok.encode(...) for opt in options} | هر گزینهٔ مجاز را به فهرست توکنهایش تبدیل کن. مثلاً «پرامپت» ممکن است یک توکن باشد و «RAG» سه توکن |
alive = [o for o ... if ids[:len(made)] == made] | گزینههایی که «تا اینجا» با توکنهای ساختهشده جور درمیآیند. اگر توکن اول ▁پ بوده، فقط گزینههایی که با ▁پ شروع میشوند زنده میمانند |
allowed = {...} | یک مجموعه (set، آکولاد بدون :) از توکنهایی که در قدم بعد مجازند |
masked = {t: x for t, x in logits.items() if t in allowed} | قلب ماجرا: فقط لاجیت توکنهای مجاز نگه داشته میشود. بقیه اصلاً به قرعه نمیرسند |
mass_kept | چند درصد از احتمال اصلی مدل مال توکنهای مجاز بود؟ اگر خیلی کم باشد، یعنی خود مدل اصلاً به این گزینهها فکر نمیکرد و ما به زور مجبورش کردهایم |
free_vs_constrained | برای آزمایشگاه: یک تولید آزاد، یک تولید محدود، و سهم هر گزینه از روی میانگین logprob (درس ۹) |
re.search(...) در بخش «۱. حصار کد» | اگر خروجی داخل حصار کد (سه بکتیک پشت هم) بود، فقط داخلش را بردار |
s.find("{") | جای اولین آکولاد را پیدا کن و هرچه قبلش است («حتماً! این هم JSON شما:») را دور بریز |
try: json.loads(s) except json.JSONDecodeError: | «امتحان کن بخوانی؛ اگر خطای JSON داد، برو سراغ تعمیر بعدی» |
re.sub(r",\s*([}\]])", r"\1", s) | ویرگولی که درست قبل از } یا ] آمده را پاک کن. \s* یعنی «هر تعداد فاصله»، \1 یعنی «همان چیزی که داخل پرانتز پیدا شد» |
حلقهٔ for ch in s: با stack | حرفبهحرف بخوان و هر آکولاد یا کروشهٔ باز را روی یک «پشته» بگذار و با هر بسته برش دار. آخر کار، هرچه روی پشته مانده یعنی بسته نشده — به همان ترتیب برعکس میبندیمشان. حواسش هست که علامتهای داخل رشته را نشمارد |
قدم ۲ — اجرا کنید
این اسکریپت دو آزمایش دارد: یک پرامپت، یک بار آزاد و یک بار فقط بین سه گزینهٔ مجاز؛ و تعمیر یک JSON نیمهکاره و پرحرف. در پوشهٔ projects/ch02_sampling بسازیدش:
"""پروژهٔ فصل ۲ (بخش ۲) — خروجی ساختاریافته.
اجرا:
.venv\\Scripts\\python.exe -m projects.ch02_sampling.run_structured
"""
from zehnyar import _console
from zehnyar.lm import get_lm
from zehnyar.structured import generate_choice, parse_json_defensive
_console.setup() # تا کنسول ویندوز فارسی را درست چاپ کند
def title(text: str) -> None:
print("\n" + "=" * 52)
print(" " + text)
print("=" * 52)
def main() -> None:
lm = get_lm()
# ---------- ۱. خروجی محدودشده ----------
title("۱. نمونهبرداری محدودشده")
options = ["پرامپت", "داده", "ارزیابی"]
q = "سه ابزار برای رام کردن مدل داریم و اولی مهندسی"
free = lm.generate(q, max_tokens=6, temperature=1.0, seed=3).text
pick = generate_choice(lm, q, options, temperature=1.0, seed=3)["choice"]
print(f"\n پرامپت : {q} ...")
print(f" آزاد : {free}")
print(f" محدود : {pick} (فقط از بین {options})")
# ---------- ۲. پسپردازش دفاعی ----------
title("۲. تعمیر JSON نیمهکاره")
broken = 'حتماً! این هم خروجی: {"topic": "نمونهبرداری", "tags": ["دما", "top-p",'
obj, fixes = parse_json_defensive(broken)
print(f"\n ورودی : {broken}")
print(f" تعمیرها: {'، '.join(fixes)}")
print(f" نتیجه : {obj}")
print("\n" + "-" * 52)
print("مدل آزاد هرچه بخواهد میگوید؛ با فیلتر لاجیت و پسپردازش، خروجی قابلاستفاده میشود.")
if __name__ == "__main__":
main()
| این تکه از کد | یعنی چه |
|---|---|
options = ["پرامپت", "داده", "ارزیابی"] | فهرست گزینههای مجاز |
lm.generate(q, max_tokens=6, ...) | تولید آزاد: مدل هرچه بخواهد میگوید |
generate_choice(...)["choice"] | تولید محدود با همان پرامپت و همان بذر؛ ["choice"] یعنی از دیکشنری خروجی فقط گزینهٔ انتخابشده را بردار |
obj, fixes = parse_json_defensive(broken) | تابع دو چیز برمیگرداند — شیء تعمیرشده و فهرست تعمیرها — و پایتون آنها را همزمان در دو متغیر میریزد |
'، '.join(fixes) | فهرست تعمیرها را با ویرگول فارسی پشت هم بچسبان |
.venv\Scripts\python.exe -m projects.ch02_sampling.run_structuredباید خروجیای شبیه این ببینید. این خروجی واقعی همین کد است:
====================================================
۱. نمونهبرداری محدودشده
====================================================
پرامپت : سه ابزار برای رام کردن مدل داریم و اولی مهندسی ...
آزاد : که فقطها آن را ک
محدود : پرامپت (فقط از بین ['پرامپت', 'داده', 'ارزیابی'])
====================================================
۲. تعمیر JSON نیمهکاره
====================================================
ورودی : حتماً! این هم خروجی: {"topic": "نمونهبرداری", "tags": ["دما", "top-p",
تعمیرها: حذف متن اضافهٔ اول، بستن 2 آکولاد/کروشه
نتیجه : {'topic': 'نمونه\u200cبرداری', 'tags': ['دما', 'top-p']}
----------------------------------------------------
مدل آزاد هرچه بخواهد میگوید؛ با فیلتر لاجیت و پسپردازش، خروجی قابلاستفاده میشود.این دو آزمایشگاه منطق همین کد را در مرورگر خودتان اجرا میکنند. گزینههای خودتان و JSONهای خراب خودتان را امتحان کنید:
قدم ۳ — وصل کردن به ذهنیار
آخرین کار: مدل تازه را پشت همان «پریز استاندارد» فصل ۱ بگذاریم تا ذهنیار هم بتواند از آن استفاده کند. اول یک ارائهدهندهٔ تازه:
"""ارائهدهندهٔ مدل زبانی محلی — همان مدل n-gram فصل ۲، پشت قرارداد Provider.
این مدل فقط **پیشآموزش** دیده، پسآموزش ندیده. یعنی جواب نمیدهد؛
متن شما را **ادامه** میدهد — دقیقاً مثل مثال پیتزا در درس ۶.
برای همین پیشفرض ذهنیار هنوز mock است؛ این یکی برای دیدن رفتار
یک مدل خام است:
$env:ZEHNYAR_PROVIDER = "local"
"""
import time
from .base import Provider, Message, Completion
class LocalLMProvider(Provider):
name = "local"
model = "zehnyar-ngram-v1"
def __init__(self):
from ..lm import get_lm
self.lm = get_lm()
def complete(self, messages: list[Message], **params) -> Completion:
start = time.perf_counter()
# مدل خام «گفتوگو» نمیفهمد؛ فقط آخرین پیام کاربر را ادامه میدهد
prompt = next((m.content for m in reversed(messages) if m.role == "user"), "")
gen = self.lm.generate(
prompt,
max_tokens=params.get("max_tokens", 40),
temperature=params.get("temperature", 0.7),
top_k=params.get("top_k", 0),
top_p=params.get("top_p", 0.9),
seed=params.get("seed"),
# بعد از علامت سؤال، محتملترین ادامه «پایان متن» است؛ نمیگذاریم خالی برگردد
min_tokens=params.get("min_tokens", 4),
)
elapsed = (time.perf_counter() - start) * 1000
return Completion(
text=gen.text,
model=self.model,
provider=self.name,
prompt_tokens=len(self.lm.tok.encode(prompt)),
completion_tokens=len(gen.steps),
latency_ms=round(elapsed, 2),
# هر توکن جدا ساخته میشود، پس اولین توکن سهم خودش را دارد
ttft_ms=round(elapsed / max(len(gen.steps), 1), 2),
cost_usd=0.0, # محلی، پس رایگان
meta={"stop_reason": gen.stop_reason},
)
| این تکه از کد | یعنی چه |
|---|---|
class LocalLMProvider(Provider): | یک ارائهدهندهٔ تازه که از قرارداد فصل ۱ ارث میبرد — پس مجبور است complete داشته باشد |
from ..lm import get_lm داخل __init__ | مدل را فقط وقتی کسی این ارائهدهنده را خواست بارگذاری کن، نه همیشه |
next((... for m in reversed(messages) if ...), "") | اولین پیام کاربر از آخر را بردار؛ اگر نبود، رشتهٔ خالی. مدل خام «پیام سیستمی» و «گفتوگو» نمیفهمد، پس فقط همان را ادامه میدهیم |
params.get("temperature", 0.7) | اگر دما داده شده همان، وگرنه ۰٫۷ |
min_tokens=params.get("min_tokens", 4) | بعد از علامت سؤال، محتملترین ادامه در درسها «پایان پاراگراف» است؛ نمیگذاریم پاسخ خالی برگردد |
return Completion(...) | همان قرارداد همیشگی، با همان سنجهها. meta دلیل توقف را هم نگه میدارد |
بعد، کارخانهٔ فصل ۱ باید اسم local را بشناسد. فایل را کامل با این عوض کنید — تنها تغییر، بلوک if name == "local": است:
"""کارخانهٔ ارائهدهنده — بقیهٔ پروژه فقط با این تابع کار دارد."""
from .base import Provider, Message, Completion
from .mock import MockProvider
from .. import config
def get_provider(name: str | None = None) -> Provider:
"""ارائهدهندهٔ فعال را برمیگرداند.
اگر ارائهدهندهٔ خواستهشده در دسترس نباشد، بیسروصدا
به mock برمیگردیم تا آموزش هیچوقت متوقف نشود.
اسم این الگو «تنزل آرام» است و در فصل ۱۰ جدیتر سراغش میرویم.
"""
name = (name or config.PROVIDER).lower()
if name == "local":
# مدل زبانی واقعی ولی خام فصل ۲ — همیشه در دسترس، چون روی همین دستگاه است
from .local_lm import LocalLMProvider
return LocalLMProvider()
if name == "ollama":
from .ollama import OllamaProvider
p = OllamaProvider()
if p.ping():
return p
print("[هشدار] Ollama در دسترس نیست — به mock برگشتم.")
elif name == "openai":
if config.OPENAI_KEY:
from .openai_compat import OpenAIProvider
return OpenAIProvider()
print("[هشدار] کلید OpenAI تنظیم نشده — به mock برگشتم.")
return MockProvider()
__all__ = ["Provider", "Message", "Completion", "MockProvider", "get_provider"]
| این تکه از کد | یعنی چه |
|---|---|
if name == "local": | اگر local خواسته شده، ارائهدهندهٔ تازه را برگردان. نیازی به ping نیست، چون روی همین کامپیوتر است و همیشه در دسترس |
حالا تنظیمات پیشفرض نمونهبرداری را در config.py تعریف میکنیم. یک بلوک SAMPLING اضافه شده؛ فایل را کامل عوض کنید:
"""تنظیمات پروژه — تنها جایی که باید برای عوض کردن مدل دست بزنید."""
import os
# گزینهها: mock (آفلاین) | local (مدل خام فصل ۲) | ollama (محلی) | openai (نیازمند کلید)
PROVIDER = os.getenv("ZEHNYAR_PROVIDER", "mock")
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:3b")
OPENAI_BASE = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_KEY = os.getenv("OPENAI_API_KEY", "")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
# قیمت تقریبی به ازای هر یک میلیون توکن (دلار) — برای تخمین هزینه
PRICING = {
"gpt-4o-mini": {"in": 0.15, "out": 0.60},
}
# پارامترهای نمونهبرداری پیشفرض (فصل ۲، درس ۸)
# دمای پایین برای دستیار پرسشوپاسخ: ثبات مهمتر از خلاقیت است
SAMPLING = {
"temperature": float(os.getenv("ZEHNYAR_TEMPERATURE", "0.3")),
"top_p": float(os.getenv("ZEHNYAR_TOP_P", "0.9")),
}
SYSTEM_PROMPT = (
"تو «ذهنیار» هستی، دستیار آموزشی مهندسی هوش مصنوعی. "
"کوتاه، دقیق و به فارسی ساده جواب بده. "
"اگر چیزی را نمیدانی، صادقانه بگو نمیدانم."
)
| این تکه از کد | یعنی چه |
|---|---|
SAMPLING = {"temperature": ..., "top_p": ...} | دمای پیشفرض ۰٫۳ و top-p برابر ۰٫۹ — جدول «نقطهٔ شروع» درس ۸: برای دستیار پرسشوپاسخ، ثبات مهمتر از خلاقیت است |
float(os.getenv("ZEHNYAR_TEMPERATURE", "0.3")) | مقدار را از متغیر سیستم بخوان (اگر بود) و به عدد اعشاری تبدیل کن. متغیر سیستم همیشه متن است، پس float(...) لازم است |
و آخر، هستهٔ ذهنیار باید این تنظیمات را به مدل برساند:
"""هستهٔ ذهنیار. در هر فصل یک لایه به این کلاس اضافه میشود.
فصل ۱: فقط یک فراخوانی ساده.
فصل ۲: پارامترهای نمونهبرداری (دما، top-p) از اینجا به مدل میرسند.
فصل ۵: کتابخانهٔ پرامپت نسخهدار اینجا وصل میشود.
فصل ۶: بازیابی متن مرتبط (RAG) قبل از فراخوانی اضافه میشود.
فصل ۱۰: نگهبان، مسیریاب و کش دور همین کلاس پیچیده میشوند.
"""
from .. import config
from ..providers import get_provider, Message
class Zehnyar:
def __init__(self, provider_name: str | None = None):
self.provider = get_provider(provider_name)
self.system = config.SYSTEM_PROMPT
def ask(self, question: str, **params):
"""یک سؤال میگیرد و پاسخ کامل بههمراه سنجهها برمیگرداند.
هر پارامتری که ندهید از config.SAMPLING میآید؛ یعنی رفتار پیشفرض
در یک جا تعریف شده و همهٔ ارائهدهندهها همان را میگیرند.
"""
messages = [
Message(role="system", content=self.system),
Message(role="user", content=question),
]
return self.provider.complete(messages, **{**config.SAMPLING, **params})
def info(self) -> dict:
return {"provider": self.provider.name, "model": self.provider.model}
| این تکه از کد | یعنی چه |
|---|---|
def ask(self, question, **params) | دو ستاره یعنی «هر تعداد پارامتر اسمدار اضافه هم قبول کن» — مثلاً bot.ask("سلام", temperature=0) |
{**config.SAMPLING, **params} | دو دیکشنری را یکی کن: اول تنظیمات پیشفرض، بعد هرچه موقع صدا زدن داده شده. اگر کلیدی در هر دو بود، دومی برنده است |
self.provider.complete(messages, **{...}) | همهٔ آن تنظیمات را به ارائهدهنده بده. mock آنها را نادیده میگیرد (جدول جستوجو دما ندارد)، ولی local، Ollama و OpenAI از آنها استفاده میکنند |
حالا پروژهٔ فصل ۱ را با مدل تازه اجرا کنید. خط اول به پاورشل میگوید «تا این پنجره باز است، provider را local بگذار»:
$env:ZEHNYAR_PROVIDER = "local"
.venv\Scripts\python.exe -m projects.ch01_foundation.runذهنیار حالا با یک مدل زبانی واقعی کار میکند — و جوابهایش بیربط است، چون فقط پیشآموزش دیده. برای برگشتن به mock، پاورشل را ببندید و دوباره باز کنید، یا بزنید: $env:ZEHNYAR_PROVIDER = "mock".
چه چیزی ساختیم؟
تمرین شما
- در
run_structured.pyفهرستoptionsرا به سه واژهٔ دیگر از درسها عوض کنید — مثلاً["توکن", "مدل", "داده"]— و ببینید خروجی محدود همیشه یکی از آنهاست، حتی اگر خروجی آزاد چیز دیگری بگوید. - در آزمایشگاه تعمیرکار بالا یک JSON بنویسید که دو اشتباه همزمان دارد: هم حصار کد دورش است و هم آخرش قطع شده. تعمیرکار از پسش برمیآید؟ حالا یک JSON بنویسید که تعمیرکار نتواند درستش کند. این همان مرز پسپردازش است: فقط اشتباههای کوچک و قابلحدس.
- با
$env:ZEHNYAR_PROVIDER = "local"پروژهٔ فصل ۱ را اجرا کنید و$env:ZEHNYAR_TEMPERATURE = "1.2"را هم امتحان کنید. پاسخهای ذهنیار چقدر عوض میشوند؟
خودآزمایی
local میگذاریم، به سؤالها جواب بیربط میدهد؟