測驗:LiteLLM 串流、非同步與多輪對話
共 5 題,點選答案後會立即顯示結果
1. 使用 stream=True 時,要從每個 chunk 取出新增的文字,應該讀哪個欄位?
2. 為什麼串流迴圈裡通常要寫 if content: 這一行?
3. litellm.acompletion() 跟 litellm.completion() 最主要的差別是什麼?
4. 在 messages 陣列中,通常放在第一個、用來設定人設或規則的 role 是哪個?
5. 為什麼多輪對話裡,除了把 user 的話 append 進 messages,還一定要把模型的回答也 append 回去?
系列第 2 篇,共 4 篇|難度:L2-進階
前置知識:已讀 #01(會litellm.completion()基本用法)、基本 Pythonasyncio概念
上一篇你已經會用 litellm.completion() 打一次模型、拿一段回覆。這一篇要處理三個「AI 一定會幫你寫、但你要看懂能不能收」的東西:
- 串流輸出(
stream=True):讓字一個一個吐出來,而不是等整段才顯示 - 非同步(
acompletion()):同時打很多次、不要卡住 - 多輪對話(
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=True,response 就是個生成器(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 驗收檢查點(串流)
- 肉眼驗收:跑起來,字應該是一個一個冒出來的。如果是「停頓幾秒,然後一次全部出現」,代表串流沒生效——通常是漏了
flush=True,或根本沒進到逐塊迴圈。 - 問 AI:把 code 貼給 AI 問
- 快速自測:在迴圈裡加一行
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)沒有 await,response 拿到的是一個「協程物件(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 裡面。普通 def 用 await 會直接語法錯誤。await 必須配 async def。
紅旗 3:明明只打一次卻硬用非同步
只發一次請求、寫個一次性腳本,卻套上 async / await / asyncio.run 一整包——不會壞,但徒增複雜度。用同步 completion() 三行就搞定的事,不需要非同步。 看到過度包裝可以請 AI 簡化。
Vibe Coder 驗收檢查點(非同步)
- 看
await有沒有配對:每個acompletion()前面都該有await;每個await都該在async def裡。 - 測併發有沒有真的變快:用
asyncio.gather打 5 次,總時間應該接近「一次的時間」,不是「五次相加」。可以用 - 問 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 問「那它的人口呢?」,模型能懂「它」是台北,就是因為前面的 user 和 assistant 訊息都還在陣列裡。
最小範例:維護對話歷史
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 驗收檢查點(多輪對話)
- 跨輪測試:第一句給模型一個資訊(例如「我叫小明」),第三、四句再問「我叫什麼?」。答得出來 = 歷史有正確維護;答不出來 = 歷史斷了。
- 印出 messages 檢查:在送出前
print(messages),確認裡面user和assistant是交替出現、而且越來越長。 - 問 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.contentacompletion()前面要await,且在async def裡messages的system/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,
await和async def有沒有配對正確?我這個情境真的需要非同步嗎?」
「這段多輪對話,模型會記得前面聊的內容嗎?assistant 的回覆有沒有正確存回 messages?」
小結
- 串流:
stream=True→for chunk→delta.content→ 要累加、要擋 None - 非同步:
acompletion()+await+async def,只在需要併發時用 - 多輪對話:維護
messages陣列,user 和 assistant 都要 append 回去
下一篇(#03)會進到 fallback 與重試:當某個模型掛掉、限流時,怎麼讓 AI 幫你寫「自動換一家、自動重試」的機制,並看懂它有沒有寫對。
進階測驗:LiteLLM 串流、非同步與多輪對話
共 5 題,包含情境題與錯誤診斷題。