推理模型會在回應前吐一大段思考。剝除函式只處理成對標籤不夠,設 max_tokens 會讓內容整個空掉,而輸出 token 暴增是最早的警報。
發布於 2026-09-06・約 5 分鐘
我有三個服務在呼叫 LLM 做結構化輸出:商品分類、文案生成、選股分析。全部要求回 JSON。
換成推理模型(thinking / reasoning 那類)之後,三個服務各自壞了一次,成因都不一樣。
其中一次讓事件引擎靜默停擺 11 天,沒有任何通知。
<think>
推理模型回應長這樣:
<think> 使用者要我分類這個商品。標題是「[限時] 三入組 保鮮盒」... 可能是廚房用品,但也可能歸到收納。我傾向 kitchen。 </think> {"category": "kitchen", "confidence": 0.9}
很多人(包括我)抓 JSON 的寫法是「從第一個 { 抓到最後一個 }」。推理段落裡只要出現一個括號,parse 就爆。
{
}
而推理段落經常出現括號——它在自言自語,會寫程式片段、會列選項、會用括號補充。
剝除函式要處理三種情況,只剝成對標籤不夠:
import re def strip_thinking(text: str) -> str: # 1. 成對 text = re.sub(r"<think>.*?</think>", "", text, flags=re.S) # 2. 只有 </think>:開頭被截掉了 if "</think>" in text: text = text.split("</think>", 1)[1] # 3. 只有 <think>:結尾被截掉了,後面沒有內容可用 if "<think>" in text: text = text.split("<think>", 1)[0] return text.strip()
第二和第三種是截斷造成的,看起來很罕見,實際上很常發生——原因見下一個坑。
max_tokens
這個最反直覺。
我從另一個供應商的程式碼沿用了 max_tokens=800,想控制成本。結果每一次呼叫都失敗。
max_tokens=800
失敗的樣子不是「JSON 被截一半」,那樣至少看得出來。實際發生的是:token 額度全部燒在 <think> 裡,思考還沒寫完就被切斷,於是回應裡只有一個沒閉合的 <think>,剝除之後 content 是空字串。
日誌上看到的是「模型回了空的」,完全指不到 max_tokens。
不設的話呢?同一批請求自然收尾在 219 到 509 tokens 之間。它本來就不會用到 800。
所以規則是:推理模型不要設 max_tokens。 你想控制的成本,用選模型和選 prompt 來控,不是用截斷。
我另外兩個服務一直沒事,因為它們從一開始就沒設這個參數。這是從別處複製設定帶過來的地雷。
我的端點會間歇性回 503,而且要等 25 秒才吐 503,不是那種立刻拒絕的快速失敗。所以它看起來很像網路問題。
如果邏輯寫成「一次失敗就退到備援模型」,結果是主線模型明明好好的,你卻長期在用備援。而備援通常比較差、或比較貴、或兩者都是。
async function callWithRetry(model, payload, tries = 2) { let lastErr for (let i = 0; i < tries; i++) { try { return await call(model, payload) } catch (e) { lastErr = e if (![429, 500, 502, 503, 504].includes(e.status)) throw e // 不該重試的直接拋 await sleep(2000 * (i + 1)) } } throw lastErr }
主模型試兩次、備援試一次,是我實測後定下來的分配。
要跟另一種狀況分清楚:整顆模型下線的時候重試沒用,要真的換一顆。分辨方法是看錯誤是否穩定重現——間歇性的重試會過,穩定的不會。
我有一張表記每次呼叫的 token 用量,本來只拿來看費用。
有一次選股訊號歸零。查下去的因果鏈是這樣的:
主供應商撞 429 → 退到備援端點的某個免費推理模型 → <think> 沒剝乾淨(那條路徑的剝除函式沒更新) → JSON 解析失敗 → 訊號歸零 → 沒有任何通知
11 天。而在錯誤日誌出現任何異常之前,token 用量表就已經在叫了:輸出 token 從每次 200~700 跳到 3,000~25,000。
全部燒在推理上,一個字都沒進 JSON。
這張表比任何錯誤日誌都早發現問題,因為它量的是「行為變了」而不是「壞掉了」。一個模型被換掉、或供應商悄悄改了預設值,最先變的一定是 token 分佈。
現在我對它設了警戒線:輸出 token 的中位數比上週高三倍就發通知。
改 LLM 路由要把所有服務都看過一遍。 我第一次遷移時漏掉一個服務,那 11 天的停擺就是這麼來的。現在改路由前會先 grep 一次全部專案,確認有幾個地方在呼叫。
徹底失敗一定要推通知。 這條聽起來理所當然,但很容易漏,因為「有備援」給人一種安全感。實際上備援鏈的每一層都可能默默地降級成一個能跑但無用的結果。
通知要節流(我設 15 分鐘),否則一輪重試五次就是五則。
我原本的結論是「AI 判斷不可靠,這種品質不能拿來自動決策」。那個結論建立在免費模型的實測上——有一顆把利空當利多給了滿分,另一顆把兩家名字相近的公司搞混。
換成付費模型跑同一批案例,12 顆有 11 顆給出正確的低分。
所以問題不在「有沒有備援鏈」,在鏈上那幾顆的品質。但每顆進鏈之前跑一次固定的驗證集,這條規則不能省——包括備援,尤其是備援,因為它平常不會被用到,壞了你不會知道。
標籤:LLM、API、JSON、推理模型、除錯