Agentic Research

This article is not yet available in English. You are reading the Traditional Chinese original. The English edition will appear here once it is translated.

Browse articles that do have an English edition

第一次呼叫 LLM API:Token、計費與常見錯誤

2026/09/3021 min readBryan Chan閱讀中文原文
TopicsLLMAPITutorial

上一篇你用 curl 打通了 API 鏈路,這篇開始正式寫程式。目標有三個:用 Python 的 openai SDK 發出第一個請求並看懂回應;理解 token 與計費的運作方式——這決定了你的程式會花多少錢;遇到 401、429 這類錯誤時知道怎麼排查。前置條件只有一個:完成 五分鐘設置你的 LLM 開發環境。

開始之前:關於 openai SDK 的三件事

第一,openai SDK 不等於只能呼叫 OpenAI。 業界大量供應商(DeepSeek、以及多數本地推理框架)提供「OpenAI 相容端點」——同樣的請求格式與介面約定。同一套程式碼,換掉 base_url 與 api_key 就能換供應商。本文範例以 DeepSeek 端點為主,換成 OpenAI 只需改兩行。

第二,你需要三樣資訊。 API key(上一篇已放進環境變數)、base_url(端點網址)、model 名稱。模型名稱各家不同、也會隨時間新增淘汰,一律以供應商官網當下公布的清單為準。

第三,安裝。 在激活的虛擬環境裡執行 pip install openai 即可。

第一個請求

完整程式碼,每行都有註解。存成 first_call.py,在專案資料夾執行 python first_call.py(Windows 用 py first_call.py):

import os
from openai import OpenAI

# 建立客戶端。api_key 從環境變數讀取,絕不寫死在程式碼裡。
# base_url 指向 DeepSeek 的 OpenAI 相容端點;換供應商就是換這一行。
client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="https://api.deepseek.com/v1",
)

# 發送一次 chat completions 請求
resp = client.chat.completions.create(
    model="deepseek-chat",  # 模型名以供應商官網清單為準
    messages=[
        {"role": "system", "content": "你是一個簡潔的助手,回答不超過三句話。"},
        {"role": "user", "content": "用一句話解釋什麼是 API。"},
    ],
)

# 回應結構:choices 是結果清單(可以要求多組),usage 是本次用量
print("回覆:", resp.choices[0].message.content)
print("用量:", resp.usage)

跑成功後你會看到模型的回覆,以及類似 prompt_tokens=xx, completion_tokens=xx 的用量資訊——xx 是這次請求實際消耗的 token 數,它是計費的依據,本節後面會展開。

回應物件的結構值得記熟,後面所有程式都圍繞它:

欄位內容
choices[0].message.content模型回覆的文字
choices[0].finish_reason為什麼停止:stop 正常講完、length 被長度上限截斷
usage.prompt_tokens輸入消耗的 token 數
usage.completion_tokens輸出消耗的 token 數

訊息角色:system、user、assistant

messages 是一個訊息清單,每則訊息都有一個 role(角色)。三種角色分工如下:

  • system:給模型定規矩與身分,例如「你是客服助手,只回答與訂單相關的問題」。它通常在清單最前面、只有一則,對模型行為的約束力最強——但注意是「最強」不是「絕對」,寫得含糊的 system 訊息約束不住任何東西。
  • user:使用者說的話。
  • assistant:模型自己先前的回覆。

這裡藏著新手最容易誤解的一件事:API 是無狀態的。 伺服器不會替你記住上一輪對話。聊天軟體看起來有記憶,是因為它每次都把完整歷史重新發送。多輪對話要自己維護:

messages = [{"role": "system", "content": "你是一個簡潔的助手。"}]

def chat(user_input: str) -> str:
    """發送一輪對話,並把雙方發言都記進 messages 清單。"""
    messages.append({"role": "user", "content": user_input})
    resp = client.chat.completions.create(
        model="deepseek-chat",
        messages=messages,
    )
    reply = resp.choices[0].message.content
    # 關鍵:把模型回覆以 assistant 角色放回歷史,
    # 下一輪請求模型才「記得」自己說過什麼
    messages.append({"role": "assistant", "content": reply})
    return reply

print(chat("我叫小君,我在學習呼叫 LLM API。"))
print(chat("我剛剛說我叫什麼名字?"))
# 第二輪能答對,不是伺服器有記憶,而是歷史被整包重發了

這個設計有一個直接後果:對話越長,每次請求的輸入 token 越多,費用越高、也越接近模型的上下文上限。 這不是 bug,是 LLM API 的基本計費邏輯。

三個最常用參數

temperature:隨機性旋鈕

LLM 生成文字時,每一步其實是在一大堆候選詞上算出一個機率分佈,然後抽一個出來。temperature 控制抽選的隨機程度:越低,模型越傾向選機率最高的詞,輸出保守、穩定、可重現性高;越高,低機率詞也有機會出線,輸出更多樣也更容易跑偏。

多數供應商的取值範圍是 0 到 2 之間的一段(確切範圍與預設值以各家文件為準)。實務原則:分類、抽取、程式碼生成這類「有標準答案」的任務用低值;文案發想、命名這類「要創意」的任務用高值。

max_tokens:輸出長度上限

限制模型這一次最多生成多少 token。撞到上限時回應會被硬生生截斷,finish_reason 顯示 length——如果你發現回覆總是「說一半」,先檢查這個參數。

兩個易混淆點:一,它限制的是輸出,輸入長度由模型的上下文視窗(context window,模型單次能看到的輸入加輸出總量)決定,是兩件事;二,部分較新的 OpenAI 模型把參數改名為 max_completion_tokens,OpenAI 相容端點則普遍仍接受 max_tokens,以供應商文件為準。

stream:串流輸出

預設情況下,API 等模型全部生成完才一次回傳,長回覆會讓使用者盯著空白畫面等。stream=True 改成邊生成邊回傳:

stream = client.chat.completions.create(
    model="deepseek-chat",
    messages=[{"role": "user", "content": "用三句話介紹什麼是 token。"}],
    stream=True,
)

# 串流模式下回應是一個個 chunk(片段),逐段印出
for chunk in stream:
    delta = chunk.choices[0].delta.content  # 這一片段新增的文字
    if delta:
        print(delta, end="", flush=True)
print()

ChatGPT 網頁版那種「文字一個個冒出來」的效果,底層就是它。互動式產品幾乎必用串流;批次處理的腳本則不需要。

一個實務細節:串流模式下,usage 用量資訊通常要等最後一個 chunk 才拿得到,而且部分供應商需要你明確傳入額外選項才會回傳(例如 OpenAI 的 stream_options)。如果你要做成本統計,先確認你的供應商在串流模式下怎麼給 usage,以各家文件為準——否則你會發現日誌裡全是空的用量欄位。

Token 與計費:錢是怎麼算的

token 是什麼

token 是模型處理文字的最小單位。模型不吃「字」,吃 token——文字先被 tokenizer(分詞器)切成一片片的 token,模型再逐個處理與生成。英文一個 token 大約是幾個字元;中文的切法依各家 tokenizer 而異,同樣一句話在不同模型下的 token 數可能不同。OpenAI 官方文件給過「英文約四個字元 ≈ 1 token」這類粗略經驗值,但經驗值只能用來抓感覺,不能用來對帳。

想知道確切數字只有兩條路:事後看 API 回應裡的 usage 欄位(最準,供應商按它計費);事前估算用對應的 tokenizer 工具(OpenAI 模型可用 tiktoken 這個 Python 套件,其他供應商看自家文件是否提供)。

輸入與輸出分開計價

所有主流供應商都採「按 token 計費」,而且輸入(prompt tokens)與輸出(completion tokens)單價不同——多數情況下輸出單價高於輸入。這不是任意的定價遊戲,背後有技術原因:讀輸入時模型可以一次性平行處理整段文字,生成輸出時卻必須逐 token 接續計算,每一步都佔用算力。這個結構對成本有直接含意:「長輸入、短輸出」的任務(例如文件摘要、資訊抽取)與「短輸入、長輸出」的任務(例如長文生成),即使總 token 數相同,帳單也完全不同。部分供應商還對重複的輸入前綴提供快取折扣。這些都是計費結構上的通則,具體單價一律以供應商官網定價頁為準,本文不寫任何金額:價格隨時在變,寫進文章就是過時的錯誤資訊。

由此推出兩條省錢原則,比背價格有用得多:

  1. 輸入端:別把用不到的內容塞進 prompt。整個對話歷史每輪重發、把萬行文件整包丟進去,都是按 token 收錢的。
  2. 輸出端:用 system 訊息與 max_tokens 約束回覆長度。「請簡短回答」四個字常常比你想像中省錢。

怎麼算自己的用量

公式很簡單:花費 = 輸入 token 數 × 輸入單價 + 輸出 token 數 × 輸出單價。單價去官網查,token 數從每次回應的 usage 拿。

讀定價頁時有個容易算錯的細節:各家標價的計量單位不一定相同——有的按每一千 token 標價、有的按每一百萬 token 標價,比較之前先把單位對齊,否則帳面數字會差一千倍。務實做法是在你的程式裡把每次呼叫的 usage 寫進日誌:

import json

def log_usage(resp, task: str):
    """把每次請求的用量追加寫入 usage.log,方便月底統計。"""
    record = {
        "task": task,
        "prompt_tokens": resp.usage.prompt_tokens,
        "completion_tokens": resp.usage.completion_tokens,
    }
    with open("usage.log", "a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")

跑了幾百次之後把日誌加總,乘以官網單價,就是你的成本。另外兩件事要主動做:到供應商控制台看用量儀表板並設定花費上限或告警(多數平台支援,位置各異);開發與測試階段先用較小較便宜的模型,上線前才換大模型。本站對多家供應商定價的整理可參考 LLM API 定價整理,但請把它當「去哪查、怎麼比」的地圖,下決定前仍以官網即時價格為準。

常見錯誤與排查

401:身分驗證失敗

AuthenticationError。可能原因依機率排序:key 複製時混入空格或換行;用了 A 家的 key 打 B 家的 base_url(最常見於混用 DeepSeek 與 OpenAI);key 已被撤銷;帳號未開通 API 權限或未充值。先確認 key 與 base_url 是同一家供應商的,再逐項排查。

429:請求太多

RateLimitError。這個狀態碼底下其實有兩種完全不同的狀況,要看錯誤訊息本文:

  • 限流(rate limit):你在短時間內發了太多請求,或併發數超過帳號等級的上限。解法:降低頻率、加入重試並讓重試間隔逐次拉長(稱為指數退避),或到控制台提升額度。
  • 餘額不足 / 配額用盡:部分供應商餘額耗盡時也回 429。解法:充值。錯誤訊息裡通常寫得很明白。

context length exceeded:上下文超長

通常以 400 BadRequestError 回來,訊息中會出現 context length、maximum tokens 之類的字眼。原因是「輸入 token + 你要求的輸出上限」超過了模型的上下文視窗。最常見於多輪對話:歷史越滾越長,某一輪突然爆掉。解法依序考慮:裁掉最舊的對話(保留 system 與最近幾輪);把舊對話摘要成一段文字再放回;換上下文視窗更大的模型(單價通常不同,回到官網查)。

JSON 解析失敗

你叫模型輸出 JSON,json.loads() 卻報錯——因為模型有時會在 JSON 外面包一層「好的,以下是結果:」之類的廢話,或把它放進 Markdown 程式碼區塊。三層解法:

import json

resp = client.chat.completions.create(
    model="deepseek-chat",
    messages=[
        {"role": "system", "content": "你只輸出合法的 JSON,不要輸出任何其他文字。"},
        {"role": "user", "content": '列出三種程式語言,JSON 格式:{"languages": [...]}'},
    ],
    # 結構化輸出模式:要求端點回傳合法 JSON(支援度以各家文件為準,
    # 且多數實作要求 messages 中必須出現「JSON」字樣)
    response_format={"type": "json_object"},
)

text = resp.choices[0].message.content
try:
    data = json.loads(text)
except json.JSONDecodeError:
    # 保底:剝掉可能包著的 Markdown 程式碼圍欄再試一次
    cleaned = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
    data = json.loads(cleaned)  # 再失敗就記錄原文並告警,別讓程式無聲崩掉

逾時與連線錯誤

APITimeoutError / APIConnectionError:網路不穩、代理設定、或供應商短暫故障。建立 client 時可傳入 timeout 與 max_retries 參數讓 SDK 自動處理基本重試。

一個統一的錯誤處理骨架:

from openai import APIStatusError, APIConnectionError, APITimeoutError

try:
    resp = client.chat.completions.create(
        model="deepseek-chat",
        messages=[{"role": "user", "content": "你好"}],
    )
except APIStatusError as e:          # 伺服器回了 4xx/5xx 狀態碼
    print(f"HTTP {e.status_code}: {e.message}")
except (APITimeoutError, APIConnectionError) as e:  # 根本沒連上
    print(f"網路問題: {e}")

排查的總原則:先讀錯誤訊息本文,再猜。 供應商的錯誤訊息通常已經直接說出原因,跳過它去搜尋「為什麼我的 API 不能用」是浪費時間。如果錯誤訊息看不出來,按這個順序做系統性排查,每一步都在縮小範圍:

  1. 最小重現:用最短的一句話 prompt 直接呼叫一次。成功,代表問題出在你原本請求的內容(太長、格式錯、參數錯);失敗,代表問題出在帳號、key 或網路層。
  2. 隔離變數:一次只換一個東西——換一把確定有效的 key、換一個確定可用的模型名、換回官方預設參數。同時改三樣東西等於什麼都沒測。
  3. 看控制台:登入供應商控制台,確認帳號狀態、餘額、用量曲線與速率限制等級。很多「程式問題」其實是帳務問題。
  4. 看狀態頁:供應商都有 status page,先確認不是對方在故障,再回去改自己的程式碼。

下一步