【LiteLLM 完全入門】#02 串流、非同步與多輪對話實戰

測驗:LiteLLM 串流、非同步與多輪對話

共 5 題,點選答案後會立即顯示結果

1. 使用 stream=True 時,要從每個 chunk 取出新增的文字,應該讀哪個欄位?

  • A. chunk.choices[0].message.content
  • B. chunk.content.text
  • C. chunk.choices[0].delta.content
  • D. chunk.stream.content

2. 為什麼串流迴圈裡通常要寫 if content: 這一行?

  • A. 為了讓輸出比較好看
  • B. 有些 chunk 的 content 是 None,不擋掉會出錯或印出 None
  • C. 為了讓串流跑得比較快
  • D. 因為 content 一定要轉成字串才能印

3. litellm.acompletion()litellm.completion() 最主要的差別是什麼?

  • A. acompletion 是非同步版本,要搭配 await 使用
  • B. acompletion 只能用來串流輸出
  • C. acompletion 回傳格式完全不同,要用特殊方法解析
  • D. acompletion 不需要傳 messages 參數

4. 在 messages 陣列中,通常放在第一個、用來設定人設或規則的 role 是哪個?

  • A. user
  • B. assistant
  • C. developer
  • D. system

5. 為什麼多輪對話裡,除了把 user 的話 append 進 messages,還一定要把模型的回答也 append 回去?

  • A. 為了節省 token 費用
  • B. 模型本身沒有記憶,不存回去下一輪它就不記得自己說過什麼
  • C. 這是 LiteLLM 強制規定,不做會報錯
  • D. 為了讓串流輸出正常運作

系列第 2 篇,共 4 篇|難度:L2-進階
前置知識:已讀 #01(會 litellm.completion() 基本用法)、基本 Python asyncio 概念

上一篇你已經會用 litellm.completion() 打一次模型、拿一段回覆。這一篇要處理三個「AI 一定會幫你寫、但你要看懂能不能收」的東西:

  1. 串流輸出stream=True):讓字一個一個吐出來,而不是等整段才顯示
  2. 非同步acompletion()):同時打很多次、不要卡住
  3. 多輪對話messages 陣列):讓模型「記得」前面聊過什麼

這三個是 AI 幫你接 LLM 時最常寫的樣式。看不懂的話,你根本不知道它交的 code 到底對不對。


Part 1. 串流輸出:字一個一個吐出來

一句話說明

stream=True 讓模型邊生成邊回傳,你收到的不是一整段文字,而是一連串小碎片(chunk),要自己把它們接起來。

最小範例

import litellm

response = litellm.completion(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "用一句話介紹台北"}],
    stream=True,   # ← 關鍵:打開串流
)

for chunk in response:                       # response 是可以迴圈的串流物件
    content = chunk.choices[0].delta.content # 取出這一小塊的文字
    if content:                              # 有些 chunk 的 content 是 None,要擋掉
        print(content, end="", flush=True)   # end="" 不換行,flush 立刻顯示
Code language: PHP (php)

跑起來你會看到字一個一個冒出來,像打字機一樣。

逐行翻譯

程式碼 這在幹嘛
stream=True 告訴模型「不要等全部寫完,寫多少先給我多少」
for chunk in response: response 不再是一包資料,而是一條「水管」,逐塊流出來
chunk.choices[0].delta.content 這一塊新增的文字。注意是 delta(增量),不是 message
if content: 有些 chunk 只帶「角色」或「結束訊號」,content 會是 None,不擋掉會出錯
print(content, end="", flush=True) 印出來但不換行、不緩衝,才看得到即時效果

關鍵差異:delta vs message

這是串流最容易搞錯的地方,一定要記住這張對照:

# 非串流(#01 教的):一次拿整段,走 message
text = response.choices[0].message.content

# 串流:一塊一塊拿,走 delta
piece = chunk.choices[0].delta.content
Code language: PHP (php)
  • message.content = 完整的一整段(非串流才有)
  • delta.content = 這一塊新增的片段(串流才有)

如果你要在串流結束後拿到完整文字,得自己累加:

full_text = ""
for chunk in response:
    content = chunk.choices[0].delta.content
    if content:
        full_text += content   # ← 自己一塊一塊接起來
print("\n完整內容:", full_text)
Code language: PHP (php)

🚩 紅旗

紅旗 1:把串流物件當一般回應處理

response = litellm.completion(model="gpt-4o-mini", messages=msgs, stream=True)
print(response.choices[0].message.content)   # 🚩 開了 stream 還讀 message
Code language: PHP (php)

開了 stream=Trueresponse 就是個生成器(generator),沒有 .choices[0].message 可以直接讀。這樣寫會直接報錯(AttributeError)或拿到空的東西。看到 stream=True 卻用 .message.content,就是紅旗。

紅旗 2:忘記擋 None

for chunk in response:
    print(chunk.choices[0].delta.content, end="")  # 🚩 沒 if content
Code language: PHP (php)

第一個或最後一個 chunk 的 content 常常是 None,直接印會冒出 None 字樣,或後續字串拼接時炸掉。沒有 if content: 就是紅旗。

紅旗 3:忘記累加 delta

for chunk in response:
    full_text = chunk.choices[0].delta.content   # 🚩 用 = 不是 +=
Code language: PHP (php)

每一圈都把 full_text 蓋掉,最後只剩最後一小塊。要拿完整內容一定是 +=(累加)。看到串流迴圈裡是 = 而不是 +=,而且他想要完整結果,就是紅旗。

Vibe Coder 驗收檢查點(串流)

  1. 肉眼驗收:跑起來,字應該是一個一個冒出來的。如果是「停頓幾秒,然後一次全部出現」,代表串流沒生效——通常是漏了 flush=True,或根本沒進到逐塊迴圈。
  2. 問 AI:把 code 貼給 AI 問
  3. 快速自測:在迴圈裡加一行 print(type(chunk)),應該每圈都印出一個 chunk 物件(跑很多次),而不是只印一次。

Part 2. 非同步:不要卡在那裡等

一句話說明

litellm.acompletion()completion()非同步版本(名字前面多一個 a = async),可以在等模型回覆的空檔去做別的事,特別適合「同時打很多次」。

什麼時候該用非同步?

情境 用哪個
一次只打一次、寫個小腳本 completion()(同步就好,簡單)
同時打 10 次、等它們一起回來 acompletion()(非同步,快很多)
你的專案本來就是 async(FastAPI、非同步框架) acompletion()(配合框架)

一句話:只打一次、不趕時間,用同步就好,別被 AI 硬塞非同步搞複雜。

最小範例

import asyncio
import litellm

async def ask():                    # async def = 這是非同步函式
    response = await litellm.acompletion(   # await = 等這個非同步操作完成
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": "台北 101 有幾層?"}],
    )
    print(response.choices[0].message.content)

asyncio.run(ask())                  # 用 asyncio 把非同步函式跑起來
Code language: PHP (php)

逐行翻譯

程式碼 這在幹嘛
async def ask(): 宣告一個非同步函式,裡面才可以用 await
await litellm.acompletion(...) 打模型並「等它回來」,但等的時候 CPU 可以去忙別的
response.choices[0].message.content 回應格式跟同步版一模一樣,一樣走 message
asyncio.run(ask()) 非同步函式不能直接呼叫,要用 asyncio.run() 啟動

注意:非同步版拿結果的方式跟同步一樣message.content(除非你又加了 stream=True)。差別只在多了 async / await / asyncio.run

非同步真正發光的地方:同時打很多次

import asyncio
import litellm

async def main():
    cities = ["台北", "東京", "紐約"]
    tasks = [                                    # 先把三個任務準備好,還沒開跑
        litellm.acompletion(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": f"用一句話介紹{c}"}],
        )
        for c in cities
    ]
    results = await asyncio.gather(*tasks)       # 三個「同時」發出去,一起等回來
    for city, res in zip(cities, results):
        print(city, "→", res.choices[0].message.content)

asyncio.run(main())
Code language: PHP (php)

asyncio.gather(*tasks) 是重點:三次請求幾乎同時發出去,總時間約等於「最慢那一次」,而不是三次相加。如果用同步的 completion() 一個一個打,時間會是三次加總。

🚩 紅旗

紅旗 1:acompletion() 前面忘了 await

response = litellm.acompletion(model="gpt-4o-mini", messages=msgs)  # 🚩 少了 await
print(response.choices[0].message.content)  # 這裡會炸
Code language: PHP (php)

沒有 awaitresponse 拿到的是一個「協程物件(coroutine)」而不是真正的結果。你會看到類似 <coroutine object ...> 的東西,或報錯 'coroutine' object has no attribute 'choices'看到 acompletion 前面沒 await,就是紅旗。

紅旗 2:在非 async 函式裡用 await

def ask():                      # 🚩 普通 def
    response = await litellm.acompletion(...)  # SyntaxError
Code language: PHP (php)

await 只能出現在 async def 裡面。普通 defawait 會直接語法錯誤。await 必須配 async def

紅旗 3:明明只打一次卻硬用非同步

只發一次請求、寫個一次性腳本,卻套上 async / await / asyncio.run 一整包——不會壞,但徒增複雜度。用同步 completion() 三行就搞定的事,不需要非同步。 看到過度包裝可以請 AI 簡化。

Vibe Coder 驗收檢查點(非同步)

  1. await 有沒有配對:每個 acompletion() 前面都該有 await;每個 await 都該在 async def 裡。
  2. 測併發有沒有真的變快:用 asyncio.gather 打 5 次,總時間應該接近「一次的時間」,不是「五次相加」。可以用
  3. 問 AI

Part 3. 多輪對話:讓模型記得前面聊了什麼

一句話說明

模型本身沒有記憶,每次呼叫都是全新的。要它「記得」前面的對話,你得把整串歷史每次都完整送過去——這就是 messages 陣列在做的事。

messages 的結構

messages 是一個 list,每個元素是一個 dict,有兩個關鍵欄位:role(角色)和 content(內容)。

messages = [
    {"role": "system",    "content": "你是一個講話簡潔的助理"},  # 設定人設/規則
    {"role": "user",      "content": "台北在哪個國家?"},         # 使用者說的話
    {"role": "assistant", "content": "台灣。"},                   # 模型上一輪的回答
    {"role": "user",      "content": "那它的人口呢?"},           # 使用者接著問
]
Code language: PHP (php)

三種 role

role 代表誰 用途
system 系統設定 定調色、規則、人設,通常放第一個
user 使用者 你(或使用者)說的話
assistant 模型 模型之前回的話(要手動放回去它才記得)

重點:上面第二個 user 問「那它的人口呢?」,模型能懂「它」是台北,就是因為前面的 userassistant 訊息都還在陣列裡。

最小範例:維護對話歷史

import litellm

# 對話歷史從 system 開始
messages = [
    {"role": "system", "content": "你是一個講話簡潔的助理"},
]

def chat(user_input):
    messages.append({"role": "user", "content": user_input})   # 1. 把使用者的話加進去
    response = litellm.completion(model="gpt-4o-mini", messages=messages)
    reply = response.choices[0].message.content
    messages.append({"role": "assistant", "content": reply})   # 2. 把模型的回答也加回去
    return reply

print(chat("台北在哪個國家?"))   # → 台灣。
print(chat("那它的人口呢?"))     # 模型知道「它」=台北,因為歷史還在
Code language: PHP (php)

逐行翻譯(重點在這兩個 append)

程式碼 這在幹嘛
messages.append({"role": "user", ...}) 每次使用者說話,先把它加到歷史尾巴
litellm.completion(..., messages=messages) 整串歷史送出去(不是只送最新一句)
messages.append({"role": "assistant", "content": reply}) 關鍵:把模型的回答也存回歷史,下一輪它才記得自己說過什麼

如果少了第二個 append(存 assistant 回答),模型每一輪都會「忘記自己上一句講了什麼」,對話會前後兜不起來。

🚩 紅旗

紅旗 1:只送最新一句,沒送歷史

def chat(user_input):
    response = litellm.completion(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": user_input}],  # 🚩 每次都只有一句
    )
    return response.choices[0].message.content
Code language: PHP (php)

每次只送當前這句,模型完全沒有上下文,問「那它的人口呢?」它會一頭霧水。「多輪對話」卻只送單一 message,就是紅旗。

紅旗 2:忘記把 assistant 回答存回去

messages.append({"role": "user", "content": user_input})
reply = litellm.completion(model="gpt-4o-mini", messages=messages).choices[0].message.content
# 🚩 這裡少了 messages.append(assistant 回答)
return reply
Code language: PHP (php)

只存 user、不存 assistant,歷史會變成一長串使用者的問題、卻沒有任何模型的回答。模型會搞不清楚對話走到哪。user 有 append、assistant 沒 append,就是紅旗。

紅旗 3:歷史無限成長,從不裁剪

長對話裡 messages 會越來越長,token 越用越多、越來越貴,最後可能超過模型上限直接報錯。這在短腳本沒事,但做聊天機器人時要注意。看到多輪聊天完全沒有任何「限制歷史長度」的處理,是需要留意的紅旗(不一定是錯,但要問清楚)。

Vibe Coder 驗收檢查點(多輪對話)

  1. 跨輪測試:第一句給模型一個資訊(例如「我叫小明」),第三、四句再問「我叫什麼?」。答得出來 = 歷史有正確維護;答不出來 = 歷史斷了。
  2. 印出 messages 檢查:在送出前 print(messages),確認裡面 userassistant交替出現、而且越來越長。
  3. 問 AI

三個主題怎麼組合?(快速對照)

你要做的事 關鍵寫法 拿結果走哪個欄位
一般一次性呼叫 completion(...) .choices[0].message.content
逐字串流輸出 completion(..., stream=True) + 迴圈 .choices[0].delta.content(要累加)
非同步/併發 await acompletion(...) .choices[0].message.content
多輪記憶 維護 messages 陣列 + 兩次 append .choices[0].message.content

記住兩個最容易錯的點:

  • 串流走 delta、其他走 message
  • 多輪對話一定要把 assistant 回覆 append 回去

必看懂 / 知道就好

必看懂(AI 天天寫)

  • stream=True 後要用 for chunk 迴圈、走 delta.content
  • acompletion() 前面要 await,且在 async def
  • messagessystem / user / assistant 三種 role
  • 多輪對話要把 user 和 assistant 都 append 回歷史

📌 知道就好(遇到再查)

  • asyncio.gather() 做併發(要同時打很多次才用得到)
  • 串流結束後自己累加 full_text

🕰️ 認得就好

  • 有些舊 code 用 chunk["choices"][0]["delta"]["content"](字典存取),跟 chunk.choices[0].delta.content(屬性存取)等價,LiteLLM 兩種都支援

看不懂就這樣問 AI

把整段 code 貼給 AI,挑一句問:

「這段用了 LiteLLM 的串流,請一行一行用白話解釋在幹嘛,我是初學者。順便告訴我有沒有漏掉 delta.content 是 None 的處理。」

「這段非同步的 code,awaitasync def 有沒有配對正確?我這個情境真的需要非同步嗎?」

「這段多輪對話,模型會記得前面聊的內容嗎?assistant 的回覆有沒有正確存回 messages?」


小結

  • 串流stream=Truefor chunkdelta.content → 要累加、要擋 None
  • 非同步acompletion() + await + async def,只在需要併發時用
  • 多輪對話:維護 messages 陣列,user 和 assistant 都要 append 回去

下一篇(#03)會進到 fallback 與重試:當某個模型掛掉、限流時,怎麼讓 AI 幫你寫「自動換一家、自動重試」的機制,並看懂它有沒有寫對。

進階測驗:LiteLLM 串流、非同步與多輪對話

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你要做一個聊天介面,希望使用者看到 AI 的回答是「一個字一個字冒出來」的打字機效果。以下哪個做法方向正確?情境題

目標:逐字顯示、不要等整段才出現
  • A. 用 completion(),拿到 message.content 後用迴圈一個字一個字 print
  • B. 用 completion(stream=True),for 迴圈遍歷 chunk 並印出 delta.content
  • C. 用 acompletion() 就會自動變成逐字輸出
  • D. 在 messages 裡多加一個 assistant 訊息即可

2. 你的服務要同時向模型發出 20 個獨立的翻譯請求,希望總時間接近「一次請求」而不是「20 次相加」。最佳做法是?情境題

需求:20 個獨立請求,越快拿到全部結果越好
  • A. 用同步 completion() 寫一個 for 迴圈跑 20 次
  • B. 用 completion(stream=True) 串流 20 次
  • C. 用 acompletion() 建立 20 個任務,交給 asyncio.gather 一起等
  • D. 把 20 個問題全塞進同一個 messages 陣列一次送出

3. 你在做多輪客服機器人,發現使用者問「那退貨要幾天?」時,AI 完全不知道前面在聊哪個訂單。最可能的原因是?情境題

現象:AI 每一輪都像失憶,接不上前文
  • A. 每次呼叫只送了當前這一句,沒有把歷史 messages 一起送出去
  • B. 沒有開 stream=True
  • C. 沒有用 acompletion 非同步呼叫
  • D. system 訊息放在陣列最後一個

4. 以下串流程式碼想在結束後拿到「完整文字」,但 full_text 最後只剩最後一小塊。問題出在哪?錯誤診斷

full_text = “” for chunk in response: content = chunk.choices[0].delta.content if content: full_text = content # 想累加完整內容
  • A. 應該用 chunk.message.content 而不是 delta.content
  • B. if content 這行是多餘的,把它拿掉就好
  • C. full_text = content 每圈都蓋掉,應該用 full_text += content 累加
  • D. 忘了在 completion 裡加 stream=True

5. 這段非同步程式碼執行時報錯 'coroutine' object has no attribute 'choices'。問題出在哪?錯誤診斷

async def ask(): response = litellm.acompletion( model=”gpt-4o-mini”, messages=[{“role”: “user”, “content”: “hi”}], ) print(response.choices[0].message.content)
  • A. acompletion 前面漏了 await,response 拿到的是協程物件而非結果
  • B. 串流物件不能讀 choices,要改用 delta
  • C. messages 的 role 應該用 assistant
  • D. 應該把 acompletion 改成 completion

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *