ข้ามไปยังเนื้อหาหลัก
ProgrammerThailand

AI & Vibe Coding

ควบคุมค่าใช้จ่าย LLM: Token, Cache และการเลือกโมเดล

ควบคุมค่าใช้จ่าย LLM: Token, Cache และการเลือกโมเดล

หลายทีมเริ่มใช้ LLM ในระบบจริงแล้วพบว่าบิลสิ้นเดือนสูงกว่าที่คิดไว้มาก สาเหตุส่วนใหญ่ไม่ได้มาจากผู้ใช้เยอะเกินไป แต่มาจากการส่งข้อมูลเกินจำเป็น เรียกซ้ำกับคำถามเดิม และใช้โมเดลตัวใหญ่กับงานที่ตัวเล็กก็ทำได้ บทความนี้จะอธิบายว่า token คืออะไร ทำไมภาษาไทยถึงกิน token มาก แล้วพาไปดูเทคนิคลดค่าใช้จ่ายที่ทำได้จริงในโปรเจกต์ PHP/Yii2 ตั้งแต่การตัดบริบท การ cache คำตอบ แนวคิด prompt caching การเลือกโมเดลให้เหมาะกับงาน ไปจนถึงการตั้งงบและระบบแจ้งเตือน

Token คืออะไร และทำไมภาษาไทยถึงแพงกว่า

LLM ไม่ได้อ่านข้อความเป็นตัวอักษรหรือเป็นคำแบบที่เราคิด แต่จะตัดข้อความออกเป็นหน่วยย่อยที่เรียกว่า token ก่อน token หนึ่งตัวอาจเป็นคำทั้งคำ ส่วนหนึ่งของคำ หรือแม้แต่ไม่กี่ byte ผู้ให้บริการส่วนใหญ่คิดเงินตามจำนวน token ทั้งฝั่ง input (สิ่งที่เราส่งไป) และ output (สิ่งที่โมเดลตอบกลับ) และมักคิด output แพงกว่า input

ปัญหาของภาษาไทยคือ tokenizer ของหลายโมเดลถูกออกแบบมาโดยเน้นข้อความภาษาอังกฤษเป็นหลัก คำภาษาอังกฤษที่ใช้บ่อยมักกลายเป็น token เดียว แต่ข้อความไทยที่มีความหมายเท่ากันมักถูกตัดละเอียดกว่า จึงใช้ token มากกว่าอย่างเห็นได้ชัด อัตราส่วนจริงขึ้นกับ tokenizer ของแต่ละโมเดล วิธีที่ดีที่สุดคือวัดเองโดยดูค่า usage ที่ API ส่งกลับมาในแต่ละ request

ข้อสังเกตที่นำไปใช้ได้ทันที:

  • system prompt ยาว ๆ ที่เขียนเป็นภาษาไทยจะถูกคิดเงินซ้ำทุกครั้งที่เรียก ลองเขียนคำสั่งเป็นภาษาอังกฤษสั้น ๆ แล้วสั่งให้ตอบเป็นภาษาไทย มักประหยัดกว่าโดยคุณภาพไม่ลดลง
  • output ที่ยาวคือต้นทุนหลัก กำหนด max_tokens (หรือชื่อ field ที่ผู้ให้บริการใช้) ทุกครั้ง และสั่งรูปแบบคำตอบให้กระชับ
  • เก็บตัวเลข token ที่ API ส่งกลับมาทุกครั้ง อย่าเดา เพราะตัวเลขจริงคือฐานของการตัดสินใจทั้งหมดในบทความนี้

ตัดบริบทให้เหลือเท่าที่จำเป็น

ต้นทุนที่มองไม่เห็นที่สุดคือบริบทที่ส่งไปทุกครั้ง เช่น แชตบอตที่ส่งประวัติสนทนาทั้งหมดย้อนหลังไปทุก request หรือระบบ RAG ที่ดึงเอกสารมาสิบชิ้นทั้งที่ใช้จริงแค่สองชิ้น ยิ่งคุยนาน ต้นทุนต่อข้อความยิ่งสูงขึ้นเรื่อย ๆ

แนวทางที่ใช้บ่อย:

  • Sliding window ส่งเฉพาะ N ข้อความล่าสุด และตัดส่วนที่เก่ากว่าทิ้ง
  • สรุปประวัติ เมื่อบทสนทนายาวเกินเกณฑ์ ให้โมเดลตัวเล็กสรุปส่วนเก่าเป็นย่อหน้าสั้น ๆ แล้วส่งสรุปแทนข้อความเต็ม
  • คุมจำนวน chunk ใน RAG ลดจำนวนผลลัพธ์ที่ดึงมา ตั้งเกณฑ์ความใกล้เคียงขั้นต่ำ และตัดความยาวแต่ละ chunk
  • ไม่ส่งข้อมูลที่โมเดลไม่ต้องใช้ เช่น ส่ง record ทั้งแถวจากฐานข้อมูลทั้งที่ต้องการแค่สามคอลัมน์

ตัวอย่างฟังก์ชันตัดประวัติสนทนาให้อยู่ในงบตัวอักษรโดยประมาณ (ใช้ความยาวเป็นตัวแทน token แบบหยาบ ๆ เพื่อไม่ต้องพึ่ง tokenizer เฉพาะเจ้า):

<?php

declare(strict_types=1);

/**
 * เก็บข้อความล่าสุดไว้ให้มากที่สุดโดยความยาวรวมไม่เกิน $maxChars
 * @param array<int, array{role: string, content: string}> $messages
 * @return array<int, array{role: string, content: string}>
 */
function trimHistory(array $messages, int $maxChars = 6000): array
{
    $kept = [];
    $total = 0;
    foreach (array_reverse($messages) as $msg) {
        $len = mb_strlen($msg['content'], 'UTF-8');
        if ($total + $len > $maxChars) {
            break;
        }
        $kept[] = $msg;
        $total += $len;
    }
    return array_reverse($kept);
}

Cache คำตอบที่ซ้ำ

ถ้าระบบของคุณมีคำถามซ้ำบ่อย เช่น FAQ ระบบสรุปบทความที่หลายคนเปิดอ่าน หรือการจัดหมวดหมู่สินค้าที่ข้อมูลไม่เปลี่ยน การ cache คำตอบคือวิธีลดค่าใช้จ่ายที่คุ้มที่สุด เพราะ request ที่ cache hit มีต้นทุนเป็นศูนย์และตอบเร็วกว่ามาก

หัวใจของการ cache คือ key ต้องประกอบด้วยทุกอย่างที่มีผลต่อคำตอบ ได้แก่ ชื่อโมเดล system prompt ข้อความ input และพารามิเตอร์อย่าง temperature ถ้าเปลี่ยน prompt แล้วลืมเปลี่ยน key ผู้ใช้จะได้คำตอบเก่าค้างอยู่ ตัวอย่างใน Yii2:

<?php

namespace app\services;

use Yii;

class CachedLlm
{
    public function __construct(private LlmClient $client) {}

    public function ask(string $system, string $input, float $temperature = 0.0): string
    {
        $model = (string) getenv('LLM_MODEL');
        $key = 'llm:' . hash('sha256', json_encode(
            [$model, $system, trim($input), $temperature],
            JSON_UNESCAPED_UNICODE
        ));

        return Yii::$app->cache->getOrSet($key, function () use ($system, $input, $temperature) {
            return $this->client->ask($system, $input, $temperature);
        }, 86400);
    }
}

ข้อควรระวัง: ถ้าคำตอบขึ้นกับข้อมูลส่วนตัวของผู้ใช้ ต้องใส่ user id ใน key หรือไม่ cache เลย เพื่อไม่ให้คำตอบของคนหนึ่งไปโผล่ให้อีกคนเห็น และ cache เหมาะกับงานที่ใช้ temperature ต่ำซึ่งคำตอบควรคงที่ ไม่เหมาะกับงานสร้างสรรค์ที่ผู้ใช้คาดหวังคำตอบใหม่ทุกครั้ง

Prompt Caching: แนวคิดที่ควรรู้

นอกจาก cache คำตอบฝั่งเราเองแล้ว ผู้ให้บริการหลายรายมีฟีเจอร์ที่มักเรียกว่า prompt caching คือฝั่งผู้ให้บริการจำส่วนต้นของ prompt ที่ส่งซ้ำ ๆ ไว้ เมื่อ request ถัดไปขึ้นต้นด้วยข้อความเดิมทุกตัวอักษร ส่วนนั้นจะถูกคิดราคาถูกลงหรือประมวลผลเร็วขึ้น รายละเอียด เช่น ต้องเปิดใช้เองหรือไม่ ขนาดขั้นต่ำ อายุของ cache และส่วนลด แตกต่างกันไปตามผู้ให้บริการ ให้ตรวจจากเอกสารทางการของเจ้าที่คุณใช้

สิ่งที่เราทำได้เพื่อให้ได้ประโยชน์จากฟีเจอร์นี้ คือจัดลำดับ prompt ให้ ส่วนที่คงที่อยู่ด้านหน้า ส่วนที่เปลี่ยนอยู่ด้านหลัง

ไม่ดี (ส่วนที่เปลี่ยนอยู่หน้าสุด cache ใช้ไม่ได้):
[วันที่และเวลาปัจจุบัน] [คำถามผู้ใช้] [คำสั่งระบบยาว ๆ] [คู่มือสินค้า 20 หน้า]

ดี (ส่วนคงที่อยู่หน้า ใช้ซ้ำได้ทุก request):
[คำสั่งระบบ] [คู่มือสินค้า 20 หน้า] [วันที่และเวลาปัจจุบัน] [คำถามผู้ใช้]

ความผิดพลาดที่พบบ่อยคือใส่ timestamp หรือ request id ไว้ในบรรทัดแรกของ system prompt ทำให้ prefix ไม่เคยซ้ำเลย

เลือกโมเดลให้เหมาะกับงาน

ไม่จำเป็นต้องใช้โมเดลตัวที่เก่งที่สุดกับทุกงาน งานอย่างจัดหมวดหมู่ข้อความ ดึงข้อมูลออกมาเป็น JSON ตรวจภาษาไม่สุภาพ หรือสรุปข้อความสั้น ๆ โมเดลขนาดเล็กซึ่งถูกกว่าและเร็วกว่ามักทำได้ดีพอ ส่วนงานที่ต้องคิดหลายขั้น เขียนโค้ดซับซ้อน หรือวิเคราะห์เอกสารยาว ค่อยใช้โมเดลตัวใหญ่

แนวทางปฏิบัติ:

  1. เริ่มจากโมเดลเล็กก่อน แล้ววัดคุณภาพด้วยชุดทดสอบ (eval) ของงานนั้น ถ้าผ่านเกณฑ์ก็ไม่ต้องขยับ
  2. ทำ routing แยกตามประเภทงาน โดยเก็บชื่อโมเดลไว้ใน config หรือ environment ไม่ hardcode เช่น LLM_MODEL_SMALL และ LLM_MODEL_LARGE
  3. ลองแบบ cascade ให้โมเดลเล็กตอบก่อน ถ้าผลไม่ผ่านการตรวจ (เช่น JSON ไม่ตรง schema) ค่อยส่งต่อให้โมเดลใหญ่
  4. งานที่ไม่ต้องตอบทันที เช่น สรุปรายงานกลางคืน ลองดูว่าผู้ให้บริการมีโหมด batch ที่ราคาต่างออกไปหรือไม่

ตั้งงบและแจ้งเตือน

มาตรการทั้งหมดข้างต้นจะไม่มีความหมายถ้าเราไม่รู้ว่าใช้ไปเท่าไร ขั้นแรกคือบันทึก usage ทุก request ลงฐานข้อมูล แล้วค่อยต่อยอดเป็นโควตาและการแจ้งเตือน ตาราง PostgreSQL ตัวอย่าง:

CREATE TABLE llm_usage (
    id            BIGSERIAL PRIMARY KEY,
    user_id       INTEGER,
    feature       VARCHAR(50) NOT NULL,
    model         VARCHAR(100) NOT NULL,
    input_tokens  INTEGER NOT NULL DEFAULT 0,
    output_tokens INTEGER NOT NULL DEFAULT 0,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_llm_usage_user_day ON llm_usage (user_id, created_at);

จากนั้นตรวจโควตารายวันก่อนเรียก API ทุกครั้งด้วย query builder ของ Yii2:

<?php

use yii\db\Query;

function assertDailyQuota(int $userId, int $limitTokens): void
{
    $used = (int) (new Query())
        ->from('llm_usage')
        ->where(['user_id' => $userId])
        ->andWhere(['>=', 'created_at', date('Y-m-d 00:00:00')])
        ->sum('input_tokens + output_tokens');

    if ($used >= $limitTokens) {
        throw new \yii\web\TooManyRequestsHttpException('วันนี้ใช้งานครบโควตาแล้ว');
    }
}

Checklist ด้านงบประมาณ:

  • ตั้งวงเงินหรือ usage limit ในหน้า console ของผู้ให้บริการ (ถ้ามี) เป็นด่านสุดท้าย
  • มีโควตาต่อผู้ใช้และต่อฟีเจอร์ในระบบของเราเอง กันผู้ใช้คนเดียวหรือบอตยิงรัว ๆ
  • ตั้ง cron สรุปยอดรายวัน ถ้าเกินเกณฑ์ เช่น สูงกว่าค่าเฉลี่ยมาก ให้แจ้งเตือนทาง LINE หรืออีเมลทีม
  • แยก API key ตาม environment (dev, staging, production) เพื่อดูว่าค่าใช้จ่ายมาจากไหน
  • ดูรายงานแยกตาม feature ทุกสัปดาห์ ฟีเจอร์ที่จ่ายเยอะแต่คนใช้น้อยคือจุดแรกที่ควรปรับ

สรุป

  • ค่าใช้จ่าย LLM คิดตาม token ทั้งขาเข้าและขาออก และข้อความภาษาไทยมักใช้ token มากกว่า ควรวัดจาก usage จริงเสมอ
  • ตัดประวัติสนทนาและบริบท RAG ให้เหลือเท่าที่จำเป็น และกำหนดความยาวคำตอบสูงสุดทุกครั้ง
  • cache คำตอบที่ซ้ำโดยให้ key ครอบคลุมโมเดล prompt และพารามิเตอร์ และจัด prompt ให้ส่วนคงที่อยู่หน้าเพื่อใช้ประโยชน์จาก prompt caching
  • ใช้โมเดลเล็กเป็นค่าเริ่มต้น แล้วขยับขึ้นเฉพาะงานที่ eval บอกว่าจำเป็น
  • บันทึก usage ทุก request ตั้งโควตา และมีการแจ้งเตือนก่อนบิลจะบานปลาย

ลองทำ

เลือกฟีเจอร์ AI หนึ่งตัวในโปรเจกต์ของคุณ เพิ่มการบันทึก input/output token ลงตาราง llm_usage เป็นเวลาหนึ่งสัปดาห์ แล้วลองปรับสองอย่าง คือ เขียน system prompt ใหม่เป็นภาษาอังกฤษแบบกระชับ และเพิ่ม CachedLlm ครอบการเรียก จากนั้นเทียบค่าเฉลี่ย token ต่อ request ก่อนและหลังปรับ พร้อมสุ่มตรวจคุณภาพคำตอบว่ายังใช้ได้เหมือนเดิมหรือไม่

ติดปัญหาตอนทำตาม? ถามในชุมชน

ความคิดเห็น (0)

ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ