درس ۶ از ۱۲، بخش ۲: فراخوانی مدل‌ها

LiteLLM: یک رابط برای همه‌ی مدل‌ها، و دو برنامه با Streamlit

متنی ۲۵ دقیقه به‌روز شده در ۱۱ آبان ۱۴۰۵

در این درس
  1. نصب
  2. completion: یک تابع برای همه
  3. ساختار پاسخ
  4. استریم
  5. تصویر
  6. رابط کاربری با Streamlit
  7. خلاصه

تا اینجا برای هر سرویس کد جداگانه‌ای نوشتیم: client.responses.create برای GapGPT و ollama.chat برای مدل محلی. هر کدام ورودی و خروجی خودش را دارد. اگر بخواهید در یک پروژه مدل را عوض کنید، یا یک کار را با چند مدل مقایسه کنید، باید کد را بازنویسی کنید.

LiteLLM این مشکل را حل می‌کند: یک تابع completion با یک قالب ورودی و یک قالب خروجی، برای بیش از صد ارائه‌دهنده. از اینجا تا آخر بخش پرامپت‌نویسی، همه‌ی کدها با LiteLLM نوشته می‌شوند.

LiteLLM: یک تابع completion برای همه‌ی ارائه‌دهنده‌هاcompletion(model=…)یک ورودی، یک قالب خروجیLiteLLMollama/gemma3:4bمدل محلیopenai/gpt-5.2OpenAI یا سازگار با آنanthropic/…Anthropicgemini/…Google
پیشوند نام مدل تعیین می‌کند درخواست به کدام ارائه‌دهنده برود.

نصب#

pip install litellm
# اگر دسترسی به PyPI محدود است، از یک آینه‌ی داخلی:
pip install -i https://mirror-pypi.runflare.com/simple litellm

completion: یک تابع برای همه#

from litellm import completion
from dotenv import load_dotenv
load_dotenv()   # کلیدها و آدرس‌ها از .env (درس ۴)

response = completion(
    model="ollama/gemma3:4b",
    messages=[{"role": "user", "content": "پایتخت ایران کجاست؟"}],
)
print(response["choices"][0]["message"]["content"])
پایتخت ایران شهر **تهران** است.

پیشوند نام مدل مسیر درخواست را تعیین می‌کند:

نام مدل درخواست کجا می‌رود؟
ollama/gemma3:4b Ollama محلی (پیش‌فرض localhost:11434؛ با OLLAMA_API_BASE قابل تغییر)
openai/gpt-5.2 سرویس سازگار با OpenAI که در OPENAI_API_BASE تعریف کرده‌اید
openai/claude-sonnet-4-6 همان مسیر قبلی. openai/ یعنی «پروتکل OpenAI»، نه «شرکت OpenAI». GapGPT مدل Claude را با همین پروتکل سرو می‌کند
anthropic/…، gemini/… مستقیم به API خود آن شرکت‌ها (با کلید جداگانه)

برای مقایسه، یک سؤال را از چهار مدل بپرسیم. فقط یک رشته عوض می‌شود:

for model in ["ollama/llama3.1:8b", "ollama/gemma3:4b", "openai/gpt-5.2", "openai/claude-sonnet-4-6"]:
    r = completion(model=model, messages=[{"role": "user", "content": "تو کی هستی؟"}])
    print(model, "→", r["choices"][0]["message"]["content"][:120])

خلاصه‌ی جواب‌ها:

  • Llama 3.1 8B: «من یک هوش مصنوعی هستم، از یک شرکت داده کاوی …». این جواب درست نیست؛ Llama را Meta ساخته است.
  • Gemma 3 4B: «من یک مدل زبان بزرگ هستم که توسط گوگل آموزش داده شده‌ام.»
  • GPT-5.2: «من یک دستیار هوش مصنوعی هستم (مدل زبانیِ OpenAI) …»
  • Claude: «من یک هوش مصنوعی هستم که توسط Anthropic ساخته شده‌ام و نامم Claude است.»

ساختار پاسخ#

LiteLLM پاسخ همه‌ی ارائه‌دهنده‌ها را به قالب OpenAI درمی‌آورد:

import json
print(json.dumps(response.model_dump(), indent=2, ensure_ascii=False))
{
  "model": "ollama/gemma3:4b",
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {"role": "assistant", "content": "سلام! چطوری؟ چه کمکی از من می‌خوای؟ 😊\n"}
    }
  ],
  "usage": {"prompt_tokens": …, "completion_tokens": …, "total_tokens": …}
}
  • choices یک لیست است، چون می‌شود چند جواب هم‌زمان خواست. معمولاً خانه‌ی صفرم را برمی‌داریم.
  • finish_reason دلیل توقف تولید است: stop یعنی توکن پایان تولید شده و length یعنی به سقف توکن رسیده‌ایم. اگر length دیدید، جواب احتمالاً ناقص است.
  • usage تعداد توکن‌های مصرف‌شده است و برای حساب هزینه به کار می‌آید.

استریم#

response = completion(
    model="ollama/gemma3:4b",
    messages=[{"role": "user", "content": "یک پاراگراف در مورد ایران بنویس"}],
    stream=True,
)
for chunk in response:
    delta = chunk["choices"][0].get("delta", {}).get("content")
    if delta:
        print(delta, end="", flush=True)

در حالت استریم، هر chunk به‌جای message یک delta دارد که فقط تکه‌ی تازه‌ی متن را در خود دارد.

تصویر#

برای فرستادن تصویر از قالب OpenAI استفاده می‌کنیم: content به‌جای رشته یک لیست است که هم بخش متنی دارد و هم بخش تصویری. تصویر به base64 تبدیل می‌شود. base64 روشی برای نوشتن هر فایل باینری به شکل یک رشته‌ی متنی است و با پیشوند data:image/jpeg;base64, فرستاده می‌شود:

import base64

def encode_image(path):
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

response = completion(
    model="openai/gpt-4o",          # یا "ollama/gemma3:4b" برای مدل محلی
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "یه اسم زیبا برای تصویر زیر بزار"},
            {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{encode_image('images/image.webp')}"}},
        ],
    }],
)
print(response["choices"][0]["message"]["content"])
ورودی تصویر
ورودی تصویر

GPT-4o جواب داد «نبرد در برهوت» و در اجراهای دیگر «در برابر سایه‌های مه» و «نبرد با شبح طوفانی». به خاطر نمونه‌برداری، هر بار اسم تازه‌ای می‌دهد. Gemma 3 4B به‌جای یک اسم، فهرستی از پیشنهادها داد. مدل‌های کوچک کمتر به قید «فقط یک اسم» پایبندند.

رابط کاربری با Streamlit#

Streamlit با چند خط پایتون یک رابط وب می‌سازد و برای نمونه‌ی اولیه‌ی برنامه‌های LLM خیلی رایج است.

یک چت ساده#

# 02_05_Gemma_simple_UI.py
import streamlit as st
from litellm import completion
from dotenv import load_dotenv
load_dotenv()

st.title("Gemma 3 Chat via LiteLLM")
prompt = st.text_area(label="Write your prompt.")
button = st.button("Okay")

if button and prompt:
    response = completion(model="ollama/gemma3:4b", messages=[{"role": "user", "content": prompt}])
    st.markdown(response["choices"][0]["message"]["content"])
pip install streamlit
streamlit run 02_05_Gemma_simple_UI.py

مرورگر روی localhost:8501 باز می‌شود. Streamlit راست‌به‌چپ را خوب پشتیبانی نمی‌کند و فارسی کمی به‌هم‌ریخته نمایش داده می‌شود، اما کار می‌کند.

گفتگو با PDF#

حالا یک برنامه‌ی مفیدتر: کاربر یک PDF بارگذاری می‌کند و از محتوایش سؤال می‌پرسد. در کلاس با فایل «پرسش‌وپاسخ بازار سرمایه» امتحانش کردیم.

گفتگو با PDF: پرامپت افزودهفایل PDFاستخراج متنPyMuPDFپرامپت افزودهBased on the followingcontent: {متن PDF}Question: {سؤال}LLMپاسخسؤال کاربر
متن PDF و سؤال کاربر در یک پرامپت کنار هم قرار می‌گیرند.
# 02_06_Chat_with_PDF_using_Ollama_LLM.py (خلاصه)
import fitz   # pip install PyMuPDF

def extract_text_from_pdf(uploaded_file):
    doc = fitz.open(stream=uploaded_file.read(), filetype="pdf")
    return "".join(page.get_text() for page in doc)   # فقط متن؛ تصویرها نادیده گرفته می‌شوند

st.title("Chat with PDF")
uploaded_file = st.file_uploader("Upload your PDF file", type="pdf")

if uploaded_file is not None:
    with st.spinner("Extracting text from PDF..."):
        pdf_text = extract_text_from_pdf(uploaded_file)
    st.success(f"PDF loaded successfully! ({len(pdf_text)} characters)")

    prompt = st.text_area("Ask a question based on the PDF content.")
    if st.button("Okay") and prompt:
        combined_prompt = f"Based on the following content: {pdf_text}\n\nQuestion: {prompt}"
        with st.spinner("Generating response..."):
            response = completion(model="openai/gpt-5.2",
                                  messages=[{"role": "user", "content": combined_prompt}])
        st.markdown(response["choices"][0]["message"]["content"])

به combined_prompt پرامپت افزوده (augmented prompt) می‌گویند: سؤال کاربر به‌تنهایی به مدل نمی‌رود و محتوای سند هم به آن اضافه می‌شود. st.spinner هم یک نشانگر «در حال پردازش» نشان می‌دهد تا کاربر بداند برنامه کار می‌کند.

این برنامه سه محدودیت مهم دارد که در دوره‌های بعد حلشان می‌کنیم:

  1. کل PDF در هر سؤال فرستاده می‌شود. برای یک سند چندصدصفحه‌ای هم گران است و هم از پنجره‌ی کانتکست بیرون می‌زند. راه‌حل این است که فقط تکه‌های مرتبط پیدا و فرستاده شوند؛ یعنی RAG.
  2. حافظه ندارد. هر سؤال مستقل فرستاده می‌شود. اگر بپرسید «ساعت معاملات چهارشنبه چطور است؟» و بعد بگویید «آخر هفته چطور؟»، مدل نمی‌داند «چطور» یعنی چه. در درس ۱۱ می‌بینیم حافظه یعنی فرستادن تاریخچه‌ی گفتگو.
  3. فقط متن. جدول‌ها و تصویرهای PDF از دست می‌روند.

خلاصه#

  • LiteLLM با یک تابع completion و قالب پاسخ OpenAI، همه‌ی ارائه‌دهنده‌ها را یکی می‌کند. پیشوند نام مدل مسیر را تعیین می‌کند.
  • finish_reason و usage را در پاسخ چک کنید.
  • استریم با stream=True و خواندن delta، و تصویر با محتوای چندبخشی و base64 انجام می‌شود.
  • با Streamlit می‌شود در چند دقیقه یک نمونه‌ی اولیه ساخت. «گفتگو با PDF» اولین قدم به سمت RAG است.