同一個 RAG 端點,有兩個問題長得很像,卻不能用同一種測試回答。第一個是「零檢索命中的時候,它會不會確定性地回 no_answer,而且一次 LLM 都不呼叫」;第二個是「有命中的時候,它答得好不好」。第一個進了 rag_no_answer_policy.feature,第二個進了 Day 28 的 evaluation dataset。這篇講這條線怎麼畫,畫完你手上會多三個判準,可以拿去掃自己系統裡的每一個行為。
先講這篇為什麼是 bonus 而不是主線。這個系列的主線 30 天刻意沒有展開測試教學——公開範例 repo(下面都簡稱 lab)裡有 pytest,也有 behave(Python 的 BDD runner,跑的是 Gherkin 語法寫的 .feature 檔),但文章需要時只簡短引用,不開課。那條紀律讓主線省下的篇幅,正好是這篇要花的:BDD suite 從第一天跟到第三十天,卻從來沒有哪一篇停下來說明「為什麼是這些行為進了 feature 檔」。這篇補的就是那個判斷,跟主線零重疊。
數字先擺著:2026-08-27 在 day-30 那個 commit 上跑 uv run behave,輸出是 8 features passed、37 scenarios passed、184 steps passed,0 failed、0 skipped,耗時 0.282 秒。零 Azure 呼叫、零成本。為什麼做得到,第四節會拆。

那兩個問題的差別不在難度,在答案的形狀。
「零命中時回 no_answer 且不呼叫 LLM」是一個結構性的事實:檢索回來幾筆是可數的,有沒有發出 LLM 呼叫是可觀察的,回應的 status 欄位是字串比對。它在受控環境下每次都給同一個答案,所以可以寫成一條斷言,掛在 CI 上,紅了就是真的壞了。
「答得好不好」不是。它要真實語料、要人或模型評分,而且同一題跑兩次可能得到兩個分數,兩個都算合理。lab 的 Day 28 evaluation 因此逐題各跑 N 次,把每一輪的結果照序列印出來,明文不算平均、不算通過率——那個不穩定本身就是要量的東西,取平均只會把它藏起來。你當然可以硬把它寫成 scenario,但那條斷言只有兩種下場,第五節會講。
所以 lab 裡有兩套東西:behave 這條走 HTTP 邊界加合成 fixture,守 API 契約;Day 28 的 evaluation 那條走真實 corpus,守內容品質。它們互補,而且部分重疊。重疊那部分不是誰漏了誰,是刻意的,第五節有 lab 自己寫下的紀錄。
先說清楚這三個判準的身分:它們是我在這個 lab 裡實際用的設計準則,不是程式結構強制的規則。沒有任何一段 code 會因為你把品質斷言塞進 feature 檔而報錯。第五節會回來講唯一一條真的被結構擋住的界線在哪裡。
判準一:這個承諾在 API 邊界上看得見嗎。 不是「內部有沒有做對」,是「呼叫方從 HTTP 回應能不能看出來」。status code、error envelope 的 code 欄位、SSE 的事件名稱、JSON 裡有沒有 correlation_id——這些是呼叫方真的會寫 code 去讀的東西。反過來說,「retriever 內部用了哪種距離函數」不在這條線上,那是單元測試的事。
判準二:在受控 fake 之下,它是確定的嗎。 同一組輸入必須推出同一個結論。注意這裡的限定詞是「在受控 fake 之下」——不是「這個行為天生確定」,是「我有沒有辦法把不確定的部分換掉,還留下一條有意義的斷言」。零檢索命中蘊含 no_answer,這條在空語料庫的 fixture 下是結構性的,換誰跑都一樣。
判準三:它是對呼叫方的承諾,還是實作細節。 承諾的定義很土:改掉它,別人的 code 會壞。SSE 事件名從 message.done 改成別的,前端就爛了;error envelope 少一個欄位,呼叫方的錯誤處理就少一個分支。這種東西值得用一句人話寫下來,放在版本控制裡,讓改動它的人先看到那句話。
三條要同時成立才進 feature 檔。少了判準一,你寫的是單元測試套了 Gherkin 的皮;少了判準二,你買的是一條會隨機紅的 CI;少了判準三,你在替實作細節上鎖,重構時它會第一個跳出來擋路。
矩陣先擺全,一列不漏。「進場」那欄是這個檔內容成形的那個 milestone,不是檔案第一次出現的日期。有五個檔在專案骨架那天就先有了檔案,內容是後來才長出來的,拿建立日當寫作日會差上十幾二十天。另外,前五列的 Day 是 commit subject 自己寫明的,第 6、7 列則是從相鄰 milestone 的日期順延一天推出來的。推得有據,但終究是推的。
| # | feature 檔 | 進場 | 呼叫方可見的承諾 |
|---|---|---|---|
| 1 | chat_api_contract.feature |
Day 5 | 合法請求拿到帶 correlation_id 的回覆;不合法輸入與上游拒絕都收斂到同一個 error envelope;被截斷的回覆誠實回報 incomplete,不偽裝成功 |
| 2 | streaming_response.feature |
Day 6 | SSE 事件詞彙是這個 app 自訂的,不是上游的;連線正常結束時恰好收到一個終端事件;沒有終端事件的 EOF 一律視為失敗 |
| 3 | conversation_state.feature |
Day 7 | 對話歷史的權威在這個 app,不在上游;一輪只在成功後原子提交;失敗的輪次不留痕跡,重試不會弄髒歷史 |
| 4 | token_budget_guardrail.feature |
Day 9 | 每個帶 usage 的終端事件都攤出 provider 回報的用量;預算在推論之前檢查;額度用盡的對話被 429 加 error: "token_budget_exceeded" 擋下,不會再多花一次上游呼叫 |
| 5 | rag_no_answer_policy.feature |
Day 14 | 有檢索命中就帶引用生成,引用對應得回原始來源;零命中時回 HTTP 200、業務狀態 no_answer、一次 LLM 都不呼叫,不讓模型用猜的 |
| 6 | tenant_isolation.feature |
Day 15 | 一個 tenant 拿不到另一個 tenant 的文件,即使問題完全相同;群組 ACL 在同一 tenant 內部也生效;沒有限制的文件在 tenant 內任何人可讀;一個 tenant 接續不了另一個 tenant 的對話 |
| 7 | agent_endpoint.feature |
Day 18 | agent turn 與 chat turn 同構:看得到先前歷史、結果與用量原子提交;上限被觸發時回的是結構化的 incomplete reason,不是 agent 框架自己的 fallback 文字 |
| 8 | health_check.feature |
專案骨架 | 打健康檢查端點拿到 200,body 帶 status: "ok" |
八列可以收成三種承諾類型,外加自成一類的第 8 列。
第一種是「回應長什麼樣」(第 1、2 列)。這兩個檔守的是呼叫方每一次都會看到的線上格式:狀態碼、content type、事件名稱、error envelope 的欄位。streaming 那個檔的第一條 scenario 把成功路徑寫得比多數人想像的緊(streaming_response.feature:7-14):
Scenario: Successful stream delivers deltas and exactly one message.done
Given a valid streaming chat request
When I submit the request to the streaming endpoint
Then the response status code should be 200
And the response content type should be "text/event-stream"
And the stream should contain at least 2 "message.delta" events
And the stream should end with exactly one terminal "message.done" event
And the terminal event should carry status "completed" and a correlation_id
at least 2 跟 exactly one 這對量詞是重點:delta 幾個不管,終端事件必須剛好一個。同一個檔的 Feature 敘述還補了一條 scenario 本身沒寫的承諾——EOF without a terminal event must be treated as a failure。串流斷掉的時候,什麼都不發跟發一個 error 在 TCP 層上長得很像,但對呼叫方是天差地別的兩件事。這種沉默的失敗,不寫成契約就沒人守。
第二種是「狀態歸誰、額度誰扣」(第 3、4、7 列)。共同的形狀是原子性:一輪要嘛整個提交,要嘛什麼都不留。額度那個檔把「被拒絕長什麼樣」寫得很具體(token_budget_guardrail.feature:19-25):
Scenario: An exhausted conversation is rejected with the error envelope
Given a conversation token budget of 10 tokens
And a conversation with one completed turn
When I submit a follow-up message in the same conversation
Then the response status code should be 429
And the response JSON should contain error "token_budget_exceeded"
And the response JSON should contain a non-empty "correlation_id"
三件事都是呼叫方會寫進 code 的:看到 429 他會想退避重試,而 token_budget_exceeded 正好告訴他退避沒有用(這種拒絕等多久都不會自己好,得先加預算),correlation_id 則決定客訴進來時查不查得到。而這條拒絕發生在推論之前,所以它不只是一個錯誤碼,是一筆沒有花出去的錢。
同組的原子性那半沒有這麼漂亮的錯誤碼,但三個判準照樣一條一條成立:重試之後歷史裡有沒有多出一筆,呼叫方自己讀得到(判準一);fake 上游可以確定地失敗,同樣的輸入必得同樣的歷史(判準二);而「失敗的輪次不會弄髒歷史」正是呼叫方拿去寫重試邏輯的那個假設(判準三)。
第三種是「誰看得到什麼、不知道的時候怎麼辦」(第 5、6 列)。RAG 那條零命中的 scenario 就是這篇開場的那個問題(rag_no_answer_policy.feature:15-21):
Scenario: Zero retrieval hits produce a no-answer response without calling the LLM
Given retrieval that returns zero hits
When I ask the RAG endpoint the question
Then the response status code should be 200
And the RAG status should be "no_answer"
And the response should list no sources
And the LLM should not have been called
這裡有個容易讀錯的地方:狀態碼是 200,no_answer 是業務層的狀態值,不是 HTTP 錯誤路徑。「我找不到答案」是一次成功而且正確的回應,不是失敗——做成 4xx,呼叫方的錯誤處理就會開始把「沒有答案」跟「你的請求有問題」混在一起。同一個檔裡真正的 HTTP 錯誤路徑另有其人:只有空白字元的問題在檢索跑之前就被擋掉,回 422 validation_error。同一個 feature 檔,兩種拒絕,兩個層級。寫進契約,下一次重構就不會有人順手把它們抹平。
最後那行 And the LLM should not have been called 才是這條 scenario 的靈魂:零命中時不是「讓模型自由發揮、然後希望它自己說不知道」,是結構性地不給它發揮的機會。
第 6 列的租戶隔離守的東西更硬,寫法卻更白:它的第一條 scenario 就叫 "A tenant cannot retrieve another tenant's document",而 step 裡的關鍵是 tenant-b 問的是同一個問題,拿到的仍然是 no_answer。多租戶的漏法從來不是有人硬闖,是某個看起來很無害的查詢路徑忘了帶 filter,而恰好有人問了一模一樣的問題。
第 8 列自己一類:健康檢查回 200、body 帶 status: "ok",小到看起來不值得寫。但它是這八個檔裡唯一從專案骨架那天到今天一個字都沒改過的。旁邊那幾個在骨架期同樣存在的檔,後來全被改寫過一輪以上。契約真正穩定下來的樣子就長這樣:不是寫得漂亮,是沒有理由再動它。
最後補一件事:這八個檔不是一次寫齊的,有兩條 scenario 是被 review 逼出來的。Day 5 的 review 指出 invalid_input 是呼叫方看得見、OpenAPI 也記載的行為,依 DoD(definition of done,這個系列給每個 milestone 訂的完成條件)就該有 BDD scenario,於是 chat_api_contract.feature 多了一條「上游輸入拒絕收斂到 error envelope」。
Day 14 那次更值得記。review 把「無匹配文件」那條 scenario 的 fixture 從「不相關文件」換成誠實的空語料庫,順手把契約改寫成它實際上是的樣子——零檢索命中蘊含 no_answer,不是「hybrid search 找不到相關結果」。真實的向量檢索在非空索引上照樣會回最近鄰,原本那個寫法測到的是 fixture 的巧合,不是承諾。判準二在這裡救了一條假的斷言。
判準二那句「在受控 fake 之下」不是免費的。這個 lab 買它的方式寫在 tests/bdd/environment.py 的開頭(environment.py:3-10,前面兩行是 import os 與一個空行),位置本身就是設計的一部分:
# Must run before the app import below: BDD contract runs never depend on the
# local .env or shell environment (review r01 fix 2). The app builds the
# chat, search, and embedding clients at import time via build_rag_service,
# so all three fake-adapter flags are owned by the test suite here, not the
# developer's shell (Day 14 review finding 3).
os.environ["USE_FAKE_LLM"] = "true"
os.environ["USE_FAKE_SEARCH"] = "true"
os.environ["USE_FAKE_EMBEDDINGS"] = "true"
三個 fake adapter 旗標釘死在 app 被 import 之前,因為 app 在 import 時就把 chat、search、embedding 三個 client 建好了。晚一步設定就來不及,而來不及的後果不是報錯,是 behave 撿到開發者 shell 或 .env 裡的真設定去打真實 Azure。旗標的擁有權歸測試套件,不歸誰的環境。所以 37 個 scenario 沒有任何一條路徑能繞得過 fake,跑一輪 0.282 秒、零雲端成本。
忍喵:「零雲端成本」是那三行os.environ買來的,不是 behave 自帶的性質。少釘一行,這套 suite 就改用開發者機器上的真設定去打真實 Azure——而 token 用量不會在帳單上自己標明「這是測試跑的」。
代價一:fake 可能比真品寬容。 Day 31 那篇把「綠而錯」分成六種形狀,第一種就是測試替身比真品鬆——fake 收下了真工具會拒絕的參數組合,於是整套測試對著一條根本跑不起來的指令全綠。那個案例的細節在 Day 31,這裡只取結論:scenario 全綠證明的是「app 在這個 fake 之下守約」,不是「app 在真實上游之下守約」。兩者之間的距離,等於 fake 的保真度,而那是一條永遠在欠的債。
代價二:維護。 講行數要講清楚量的是哪一塊,不然數字會被拿去比錯的東西。在 day-30 那個 commit 上量三個範圍:只算 step definitions(tests/bdd/steps/*.py)是 760 行;加上 environment.py 是 813 行;再加上 8 個 .feature 檔本身是 1093 行。八段人話的契約敘述,底下墊著七百多行把它翻譯成 HTTP 呼叫的膠水。
這層膠水會腐化,除非有東西逼它更新。前面提過的那份 DoD 裡,有一條就是幹這個的:實作+測試+API 可見就更新 BDD+重新輸出 OpenAPI(CI 的 drift check 會抓)+更新被這次改動碰到的文件與圖。「API 可見就更新 BDD」那個限定詞是重點——它不要求每次改動都動 BDD,只要求改到承諾的時候動。判準一在這裡從寫作準則變成了流程掛鉤。
寫這套東西的時候,最誘人的選項是把品質也寫成 scenario。Gherkin 讀起來那麼像人話,Then the answer should mention the expense policy 打出來完全成立。我沒有這樣做,因為那條斷言只有兩種下場。
一種是 flaky。 斷言寫得夠嚴(要求特定字串、特定句式),模型換一種說法就紅,而它其實答對了。你會開始為了讓 CI 綠而放寬斷言。另一種是空洞。 放寬到不會紅的程度之後,斷言變成「回應非空」「長度大於 20」。這種東西永遠綠,包括模型胡說八道的時候。中間那個「既穩定又有鑑別力」的甜蜜點,在非確定性輸出上並不存在。
lab 對這件事的處置不是把品質趕出測試,是換一條通道,並且在那條通道內部做結構分層。Day 28 的 evaluation 契約裡,deterministic(每題必過,決定 exit code)與 judged(模型評分)是兩個不同的 key,不是同一欄位的兩個值,所以打錯字不會把一條 judged 斷言默默升級成 gate。gate_exit_code 只吃 deterministic 那張 map,並且有一條可執行的 inspect.signature 斷言擋住「有人幫它加了 judged 參數」。
exit code 也分三種:OK 0、GATE_FAILED 1、SETUP_FAILED 2——exit 1 只能代表「gate 真的跑了、而且被測的東西有問題」,缺憑證、旗標組合錯、路徑不存在一律是 2,而且全在建任何東西、打任何呼叫之前就判掉。
這條才是整篇文章裡唯一由結構強制的界線:judged 的評分結果進不了那個 0/1。注意它的作用域——它在 evaluation 內部,管的是「模型評分不准影響 gate」,不是「behave 跟 evaluation 各管一半」。behave 自己沒有這種分層,它的每一條斷言都能讓 exit code 非零。通道之間的分工靠的是第二節那三個判準,是紀律;只有這條 judged 進不了 gate 的界線是結構。紀律會漂,結構不會,而你手上多半只有紀律。
既然是紀律,重疊就一定會發生,而且 lab 自己把它寫下來了。docs/evaluation.md §1.1 的標題直接叫「Overlap with existing behave coverage, stated plainly」:
tests/bdd/features/rag_no_answer_policy.featurealready has a scenario titled "Zero retrieval hits produce a no-answer response without calling the LLM", andtests/bdd/features/tenant_isolation.featurealready covers cross-tenant and missing-group denial. Three of the ten eval cases —zero-hit-structural-no-answer,globex-oncall-ack-window-denied, andacme-asks-globex-dispute-window— assert the same behaviors those scenarios already assert.
十個 eval case 裡有三個,斷言的是 behave scenario 已經斷言過的同一批行為。同一份文件接著把界線講明白:
…while behave keeps covering the HTTP layer this dataset never touches (§2). Neither suite replaces the other, and this dataset does not claim to be the primary defense for the three behaviors it happens to share with behave.
差別在輸入:behave 用合成 fixture 在 HTTP 邊界上問「零命中會不會回 no_answer」,evaluation 用真實 corpus 問「在真的檢索得到東西的語料上,這條政策還成不成立」。同一個承諾,兩種輸入,各驗一半。這種重疊不是浪費,也不需要辯護——它是兩條通道各自完整所產生的自然交集。真正該擔心的是反過來:某個承諾兩邊都沒有,而你以為對方守著。
先講不值得的,因為那個名單比較短也比較常見。
只有一個呼叫方、而且那個呼叫方是你自己的專案,不值得。把承諾寫成人話的價值來自「有人會依賴它、而那個人不在這個 repo 裡」;沒有那個人的時候,你付了 Gherkin 那層翻譯成本,卻沒有任何人讀那段人話。同理,如果你的核心行為主要是內容品質(摘要好不好、語氣對不對),那就直接去建 evaluation,別在 behave 這層繞路。還有一種是上游難以造假的系統:判準二撐不起來,你得先付 fake 的建置成本,再永遠付它的保真度成本,收益卻只是幾條斷言。
忍喵:BDD 的售前簡報幾乎都有那張「業務、QA、工程師一起讀 Gherkin」的圖。先回答一個便宜得多的問題:你的.feature檔上一次被非工程師打開,是什麼時候?
值得的長相很具體:API 契約有多個呼叫方依賴(前端、其他服務、外部客戶),行為在 fake 之下可以確定,而且這些承諾會被反覆挑戰——重構、換上游、加功能的時候,總有人想「這裡改一下應該沒差吧」。上面那八列每一列都是這種東西。SSE 事件名、error envelope 形狀、租戶邊界,都屬於改動時最容易被順手改掉、壞掉時最難查的那一類。
成本誠實列:建置是 1093 行(含 feature 檔),其中七百多行是 step definitions 這種一次寫、之後只微調的膠水。維護綁在 DoD 的「API 可見就更新 BDD」上,代價是每個動到承諾的 milestone 多一道手續。執行接近零——0.282 秒、不打任何雲端服務,所以放進 CI 的每一次 push 都沒有痛感。看不見的那筆是 fake 保真度:它不會出現在任何帳單上,但它決定你這套綠燈值多少錢。
還有一個配比要誠實講。behave 這邊是 37 個 scenario,就是上面那八個檔的全部;同一個 commit 上,pytest 在 tests/unit/ 底下收集到 2008 個測試(--collect-only,只收集不執行,而且不含隔壁的 tests/integration/)。
這兩個數字量的不是同一件事,也不該拿來相除。擺在一起只是要講一件事:BDD 在這個 lab 裡是很薄的一層,八條講得出人話的承諾;不在那八列裡的一切(retriever 內部怎麼算、token 怎麼累加、adapter 怎麼把上游錯誤映射過來),一條 scenario 都沒有,那是那 2008 個測試的守備範圍。BDD 不是拿來取代單元測試的。把它當測試主力,你會得到一套覆蓋面很窄、讀起來卻像是很完整的測試。
behave 守的是「呼叫方可以依賴什麼」,evaluation 守的是「答案好不好」;決定一個行為進哪邊的,是它在 API 邊界上看不看得見、在受控 fake 下確不確定、以及它是不是一句對呼叫方的承諾——三條同時成立就寫成 scenario,兩邊都碰到的就讓它重疊。
現在可以做的事:打開你自己的 API,挑三個你覺得「最不能壞」的行為,逐條問那三個判準。過三關的,用一句人話寫下來放進版本控制;卡在判準二的,先問「我能不能把不確定的那段換掉」;卡在判準三的,恭喜,你剛剛發現一條你以為是承諾、其實只是實作細節的東西。
gate_exit_code),而它的作用域不涵蓋兩條通道之間的分工。environment.py)/1093(再加上 8 個 .feature 檔),都在 day-30 那個 commit 上量的。引用時請帶著範圍一起講。uv run behave):8 features/37 scenarios/184 steps 全 passed。它證明的是那一刻在那個 commit 上綠,不是別的。tests/unit/ collect 到的測試數(--collect-only,沒有真的跑),不含 tests/integration/。兩個數字擺在一起是為了講涵蓋範圍,不是為了算比例——這個系列不導出沒有分母的比率。environment.py 在 import 前釘死三個 fake 旗標,不是我逐條追過 37 個 scenario 的執行路徑。這篇的判準二說「把不確定的部分換掉,還留下一條有意義的斷言」,但 RAG 有一整類失敗,是連「該斷言什麼」都還沒想清楚:找不到、找錯、答太滿、引用錯。下一篇回到檢索那一側,把這四種失敗各自的處置補上。
那八個 feature 檔全文在 tests/bdd/features/,值得整批讀一遍。它們合起來就是這個 app 對呼叫方講過的每一句話。
evaluation 那一側的設計,包含上面引的 §1.1 重疊紀錄,寫在 docs/evaluation.md。
本篇無新增雲端資源、零成本:所有數字都來自公開 repo 在 day-30 tag 上的本機測試執行,沒有發出任何一次雲端呼叫。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。