串流 break 一下,LLM span 就不見了:openinference 在三天內替六個 instrumentation 補上離場路徑
trace 裡少一筆紀錄,比多一筆錯誤的紀錄更難發現,因為沒有人會去找一個不存在的東西。
openinference(Arize-ai 維護的 OpenTelemetry instrumentation,專門給 AI 應用用)的 PR #3796 把這件事寫得很直接:一次串流式的 chat completion,只要呼叫端提早不讀了,那次呼叫就「missing from the trace entirely」。作者的原話是:
An unended span is never exported, so a call that really happened, really cost tokens and may have already streamed a partial answer to the user leaves no record.
呼叫真的發生了,token 真的花了,使用者可能已經看到半段回答,帳上卻沒有這一筆。
三行程式碼,0 個 span
PR 附的最小重現是這樣(我照錄,沒有自己跑):
1 | with client.chat.completions.create(..., stream=True) as stream: |
作者貼的結果是,修之前,break 之後匯出的 LLM span 是 0 個;修之後是 1 個,status 為 UNSET。測試表也在同一份 PR 裡:openai 2.8.0 修前 “5 failed, 2 passed, 1 skipped”,修後 “7 passed, 1 skipped”;openai 3.16.2 修前 “6 failed, 2 passed”,修後 “8 passed”。這些是 PR 作者貼的輸出,我沒有裝套件重現。
打個比方。餐廳的點菜單要蓋「完成」章,結帳系統只認蓋了章的單。客人點了菜,廚房出了菜,客人吃到一半走了,服務生沒蓋章,這張單就永遠不進帳。菜是真的出了,成本是真的花了,帳上沒有。
蓋章這個動作對應到程式裡,就是 span 的 end。OpenTelemetry 規格對 SpanProcessor 的 OnEnd 寫得很清楚:它是在 span 結束之後才被呼叫(sdk.md,”the end timestamp is already set”)。我只確認了這一句。至於「沒 end 的 span 在每一種 SDK 與 exporter 組合下都不會匯出」,這是 PR 作者的說法,我沒有逐一查過各家實作。
哪一條路沒人蓋章
openinference 包住 openai 的串流,用的是 ObjectProxy。_stream.py(我讀的是 2026-10-26 的 main,commit 072322c)修之前長什麼樣,我只有 PR 的文字描述。同一批裡 bedrock 的 #4056 對它自己那邊的說法是:”the span only ended when the stream was read to the end or raised an error”,openai 這邊是不是同一種情況,我沒有查舊版原始碼。
PR 把根因寫成兩句:__exit__ 與 __aexit__ 只呼叫被包的 teardown 就回傳;close() 沒有覆寫,ObjectProxy 直接轉給 SDK 的物件,而在 openai 3.x,AsyncStream.aclose() 呼叫的又是被包裝的 close(),一樣繞過 wrapper。
讀者的程式碼離開串流的方式很多:離開 with、手動 close()、await aclose()、直接把物件丟掉讓它被回收。每一條都要有人蓋章,而修前蓋章的只有「讀完」那一條。
修完之後的 main 長這樣(行號是 commit 072322c 的):__exit__(L129 起)、close()(L161 起)、aclose()(L179 起)都在 finally 裡收 span;__del__(L204 起)是最後一道,註解寫 “the span must still be ended, otherwise the call is missing from the trace entirely”,而且吞掉所有例外。
同一個洞,在別處也補過
如果只有 openai 一家漏,這是一個 bug。但 PR #4085(together)的內文自己就列了一串:
This is the same defect already fixed in the other instrumentors: cohere #3918, mistralai #3989, anthropic #3999, ollama, google-genai #3961.
我整理了本文用到的八個 PR,時間都是 GitHub 回傳的 merged_at,UTC:
離場路徑補丁的合併順序(UTC)
2026-10-20 04:19 ollama #4029
KeyboardInterrupt 與 asyncio.CancelledError 都繼承自 BaseException,Ctrl-C、被取消的 task、asyncio.wait_for 逾時、client 斷線,都會讓 chat() 的 span 永遠不結束。(#4029)
2026-10-20 04:21 together #4085
串流被關閉或丟掉時不匯出 span,PR 附的失敗測試是 assert 0 == 1。(#4085)
2026-10-20 04:22 google-genai #4086
GeneratorExit 在呼叫端 break、close()、aclose() 或丟掉 iterator 時拋出,原本既不進錯誤分支,也不走 _finish_tracing();except Exception 改成 except BaseException。(#4086)
2026-10-20 04:23 litellm #4016
提早關閉時,改成記下已收到的部分輸出。(#4016)
2026-10-20 04:28 bedrock #4056
涵蓋 converse_stream、invoke_model_with_response_stream、invoke_agent、retrieve_and_generate_stream。(#4056)
2026-10-20 04:56 litellm #3948
失敗或取消的串流、從來沒被讀過的串流,以及 response.incomplete、response.failed 這類 Responses 終端事件。(#3948)
2026-10-22 02:46 openai #3796
前面那三行重現程式碼出自這一支。(#3796)
2026-10-22 02:47 litellm #4015
串流中途失敗原本是 UNSET,改成 ERROR。(#4015)
十月二十日那天,六支 PR 在 37 分鐘內合併完(04:19:31 到 04:56:44)。時間戳能告訴我的只有這件事,為什麼擠在一起,我不知道。
這串修復有三種「中途離開」,值得分開看,因為它們被漏掉的原因不同:
呼叫端自己不讀了(break、close()、丟掉物件),例外型別是 GeneratorExit 或根本沒有例外。被外力打斷(Ctrl-C、取消、逾時、斷線),例外型別是 BaseException 這一支,只攔 Exception 的 handler 看不到它。還有一種最安靜的:串流根本沒開始被讀。#3948 的內文指出,generator 沒開始跑,finally 就不會跑。
#3948 也交代了成本:作者寫,開著的 span 永遠不匯出,而任何追蹤存活 span 的 span processor 會一直持有它,他們是在 LiteLLM proxy 的部署裡看到記憶體穩定上升才發現的。這是 PR 作者的自述,我沒有重現。
蓋哪一種章
修漏洞容易,難的是「提早離場」要蓋什麼章。
OpenTelemetry 的 span status 有 Unset、Ok、Error 三值。#3796 的選擇是:每一條離場路徑都收掉 span,但 status 留成 UNSET,原文的理由是讓「被截斷的串流」與「完成的(OK)」或「失敗的(ERROR)」分得開。
程式碼裡的判斷在 _finish_tracing_on_exit(L236 起):例外是 None,或是 GeneratorExit,status 就是 UNSET;其他例外走 ERROR。另一個函式 _finish_tracing_with_error(L248 起)的註解把差別講完了:”A stream that fails or is cancelled is an error, unlike one that is just left early.”
這個選擇還有一個背景。#3796 描述修前各家的現況:together 與 cohere 在 __exit__ 時以 OK 收尾,anthropic 與 mistralai 沒有 teardown 處理,ollama 的慣例才是 UNSET,這支 PR 說自己跟的是 ollama。換句話說,同一個「讀到一半走掉」的情境,修前有的家把它記成成功,有的家根本沒記。
然後 #4015 補上最後一角:串流中途失敗,原本只 record_exception、status 留 UNSET,而非串流的 wrapper 早就會設 StatusCode.ERROR,結果失敗的串流在錯誤視圖與告警裡看不到。改成 ERROR 之後,三個狀態才各自對應一種結局。
這個「UNSET 代表中途放棄」的約定,我在 spec 裡找不到。我 grep 了 spec/semantic_conventions.md、spec/llm_spans.md、spec/traces.md 三份檔案的 stream 與 UNSET,沒有任何和串流提早結束有關的規定;traces.md 只定義了三個 status 值,並說在已知有錯誤時才設 status。configuration.md、tool_calling.md 等其他 spec 檔我沒有 grep,這一點未查證。
我的立場,與會讓我改變想法的條件
自己包串流的人,應該把離場路徑一條一條列出來蓋章,而且「提早離場」的 status 要和「讀完」不同。我認為 UNSET 是目前看得到的最合理的選擇,理由有兩個:它不會把一次沒讀完的呼叫算進成功率,也不會把使用者自己按停止算成錯誤。
但這個立場有一個我查不到的前提:後端怎麼顯示 UNSET。如果主流的 trace 後端把 UNSET 預設歸成「成功」,或預設濾掉,那「用 UNSET 標記截斷」就失效了,被截斷的呼叫會再次從畫面上消失,只是換成消失在 UI 層。我沒查過各家後端的行為。另一個會讓我改主意的情況是 spec 之後真的寫了串流提早結束該用什麼 status,而答案不是 UNSET,那這一串修復就得跟著再改一輪。
還有一個我看不出答案的地方。為什麼同一個缺陷會在這麼多 instrumentation 各自出現?我只看到 together、mistralai、openai 的 _stream.py 各有一份幾乎同形的 close() 與 __del__,有沒有共用的基底類別我沒有逐檔確認,所以不拿它下結論。
帶得走的檢查
正常路徑只有一條,讀到最後。離場路徑有好幾條:__exit__、close、aclose、__del__、GeneratorExit,還有繞過 except Exception 的 BaseException。只在正常路徑驗證的測試,永遠看不到這些漏洞。PR 的測試表是這樣:修前 openai 2.8.0 有五個失敗、3.16.2 有六個失敗,修後全數通過。這些失敗的測試是不是走離場路徑,我沒有逐支看過。
所以看一個包了串流的 instrumentation,問一個問題就夠了:這次呼叫要是在讀到一半時消失,它在帳上會變成什麼。是一筆 OK、一筆 ERROR、一筆標了 UNSET 的截斷紀錄,還是什麼都沒有。
最後補一個取得日期:這些數字取自 2026-10-26,repo 當時是 1,285 星、367 forks,Apache-2.0 授權,沒有封存。我只讀了 PR 與 main 上的原始碼,沒有裝過任何一個 instrumentation,也沒有親手重現過那三行程式碼。










