學習 / 實作
Java LLM 實踐(九):取消請求後,舊回答為什麼還會覆蓋新會話?
AI 應用 / 構建 · 工程案例
18 種固定執行緒交錯,驗證流式預覽、完成和錯誤回呼的請求歸屬,以及取消和有限冪等的邊界。
這篇之後,已經有了哪些結果?
保留本文發表時的結論與下一步構想;以下是已發布的後續內容,不代表所有侷限皆已解決。
繼續探索
繼續探索
瀏覽統計暫不可用
本文目錄 11
上一輪解決了一個協議問題:流式回答必須正常結束,才能把問答寫入歷史。但完整回答也可能屬於一個已經被取消的舊請求。
假設 A 正在回答,使用者取消 A,立即發送 B。取消發生時,A 的最後一個回呼可能已經排進執行佇列。如果回呼只負責 setText(answer),舊回答仍然能寫到 B 的位置。即使 B 稍後覆蓋回來,頁面中途也顯示過錯誤內容。
這篇把問題從“回答完整嗎”推進到“這次更新還屬於當前請求嗎”。
先選產品語義,再選並行策略
事務式歷史、版本票據與本篇服務不同的產品要求。後篇不代表前篇一律錯誤。假設使用者先發出 A,再發出 B:
| 產品要求 | 策略 | B 是否包含 A 的回答 | A 晚到時怎麼辦 |
|---|---|---|---|
| 兩個問題都要回答,B 延續 A 的上下文 | 同會話序列執行(Demo 05) | A 成功提交後 B 才取歷史,因此包含 | B 排隊,正常提交不會越過 A |
| 允許基於相同歷史並行求解,只接納一份提交 | 先提交者勝出(Demo 06) | A/B 同時取得版本 v 時不包含 | 先合法提交者使 v 失效;另一份重試前須重新取得歷史 |
| B 表示新的意圖,舊回答不應繼續改介面 | 最新發起請求勝出(Demo 09) | 不保證;B 開始時 A 若未提交就不包含 | B 開始即撤銷 A 的寫入資格,即使 A 較早完成也拒絕 |
以下是語義推演,不是新增實驗:
序列執行: 發起 A → 提交 A → 發起 B → 提交 B
先提交者勝出: 發起 A(v0) → 發起 B(v0) → A 提交(v1) → B 拒絕
最新發起勝出: 發起 A(e1) → 發起 B(e2) → A 拒絕 → B 提交如果 B 發起後失敗,A 後來成功,三者仍不同。序列執行保留已提交的 A;版本票據模式中,B 未提交就不消耗版本,A 仍可能提交;本篇的最新意圖模式已撤銷 A,不因 B 失敗而復活。若要保留兩份答案,應使用佇列或獨立分支,不要硬套單一活動請求模型。
小練習:把「最新請求」理解成「最後完成的請求」,會怎樣?
A 發起早但回傳慢,B 發起晚卻回傳快;最後完成者勝出會讓 A 覆蓋 B,違反最新意圖的語義。必須依請求身分與代次判斷,不依網路返回順序。本篇的 late-preview 是可執行反例。
先看發生錯誤的時刻
下面是 late-preview 案例的實際調度順序。A 的工作執行緒停在預覽回呼前,主執行緒啟動 B,再放行 A:
主執行緒:begin(A)
工作執行緒:已經解析出 answer-A,等待放行
主執行緒:begin(B) → preview(B, answer-B)
工作執行緒:preview(A, answer-A) → complete(A)
主執行緒:complete(B)不檢查歸屬的實現,在第 4 步把 answer-B 改回了 answer-A,並將舊問答寫入歷史。只檢查最後一張截圖,很容易遺漏這個錯誤。因此紀錄保存了每次回呼後的預覽和歷史,審計會檢查整條軌跡。
實驗到底測了什麼
使用 Java 8、一個真實工作執行緒和主執行緒。輸入是合成的記憶體 SSE,解析器原樣重用上一篇的文本協議。本輪不呼叫 Agnes,也不重新測試網路傳輸。
通過兩把 CountDownLatch 控制先後關係:工作執行緒告訴主執行緒“已經到達指定位置”,主執行緒完成取消、清空或切換後,再放行回呼。這比不斷運行帶 sleep 的測試更明確:每次都確實進入所聲稱的交錯。
Java 8 CountDownLatch 文檔定義了等待與放行之間的可見性關係。這裡用它構造可重複測試,不用它作為產品會話鎖。等待設置了 5 秒上限;超時視為測試失敗,不作為模型延遲。
| 固定場景 | 任意回呼均可寫入 | 僅當前請求可寫入 |
|---|---|---|
| 正常完成 | 通過 | 通過 |
| 取消後舊回答完成 | 失敗 | 通過 |
| 清空後舊回答完成 | 失敗 | 通過 |
| 切換 B,A 先完成 | 失敗 | 通過 |
| 切換 B,B 先完成 | 失敗 | 通過 |
| B 已預覽,A 的預覽遲到 | 失敗 | 通過 |
| 同一完成回呼執行兩次 | 失敗 | 通過 |
| SSE 截斷 | 通過 | 通過 |
| B 已開始,A 的錯誤遲到 | 失敗 | 通過 |
18 次執行中,樸素對照有 7 種交錯違反預期,帶歸屬檢查的 9 種全部通過。它是有限場景的確定性驗證,不是線上故障率,也不是所有並發情況的證明。
一個請求需要一張“寫入通行證”
Session.begin 返回一個 Ticket,里面包含:
owner:創建它的會話物件,外部不能構造或更換。epoch:請求代次。開始、取消和清空都會使舊代次失效。id與question:請求標識和對應問題。
寫入前的核心檢查只有一行:
t.owner == this && t == active && t.epoch == epoch三項檢查各自有作用。owner 拒絕把另一個會話的憑據拿來用;物件身份限定當前請求;epoch 明確表達失效代次。它們不是登錄認證,也不能替代服務端鑒權。
僅檢查“版本號相同”還不夠描述整個生命周期。A 完成後即使沒有新請求,也不能再次提交。所以成功提交會把 active 清空,重複完成自然被拒絕。
檢查與修改必須在同一把鎖里
下面的寫法仍然存在競態:
if (isCurrent(ticket)) {
// 此處可能發生 cancel() 或 begin(B)
history.add(answer);
}本例讓 preview、complete、cancel、clear 和 fail 都在同一個 Session 的同步方法里執行。核對歸屬和修改狀態構成一個不可分割的短操作。
網路讀取和解析不放在鎖里:
Session.Ticket ticket = session.begin(id, question);
if (ticket == null) return; // 本地 ID 保留窗內重複,不啟動請求
try {
String answer = new StreamReader().read(input,
text -> session.preview(ticket, text));
session.complete(ticket, answer);
} catch (IOException ex) {
session.fail(ticket);
}這裡仍然依賴上一輪的前置條件:StreamReader.read 只有在 stop 和 [DONE] 都滿足時才返回。complete 本身不解析 SSE;不要繞過呼叫邊界,把半句文本直接塞給它。
錯誤回呼也要核對歸屬
一個容易漏掉的分支是 catch。如果 A 遲到的錯誤執行了“清空 loading、清空預覽”,B 即使沒有被舊答案覆蓋,也會失去自己的狀態。
late-error 案例專門驗證這一點:B 已經預覽,A 才進入 fail。帶歸屬檢查的實現記錄 STALE,不修改 B。錯誤不是擁有更高權限的回呼,它也屬於某個具體請求。
取消不等於遠端停止
本輪的取消含義是:從這一刻開始,A 不能再改變本地當前會話。它不保證:
- 遠端模型立即停止生成或計費;
- 阻塞的網路讀取已經被中斷;
- 多臺服務器共享同一失效代次;
- 行程重啟後仍能識別舊 ID。
工程接入時仍需關閉連接、處理超時,必要時呼叫供應商支援的取消機制;即便這些操作失敗,本地歸屬檢查也應繼續有效。
有界冪等不是永久冪等
示例保留最近 8 個已用 ID 和 8 對完整問答。重複 ID 不啟動新請求;同一 ID 換問題也拒絕。清空內容不會立即清掉 ID 保留窗,避免清空後舊操作被意外重放。
當 ID 被淘汰後,它可以再次使用。因此這是單行程、有限視窗的防重複機制。需要長期請求去重時,應使用持久化收據、明確生命周期和認證後的會話鍵,不能把這個示例的集合直接稱作生產冪等服務。事件快照只用於本輪小型實驗,生產紀錄也需要容量限制與脫敏。
重現前先檢查環境
使用乾淨目錄固定本篇版本,再執行下方 run.py;首篇舊提交不含 Demo 09:
git clone https://github.com/VirtualSelect/hohoo-ai-lab.git hohoo-ai-lab-session
cd hohoo-ai-lab-session
git checkout 920fee3717a373dc796269ee3a4a03ec33700ae3
java -version
mvn -version需要 Python 3、相容 Java 8 的 JDK 與 Maven。首次下載 Gson 相依套件時不要加 --offline。將下方 /path/to/mvn 換成實際執行檔(Windows 可用 mvn.cmd)。預期兩種實作各 9 條軌跡;檢查中間預覽,不僅看最終答案。找不到 Maven 時檢查 PATH;找不到目錄時核對提交;輸出目錄已存在則換新名稱,不覆蓋封存。
2026-10-06 複核邊界: 沿用的 Gson 2.10.1 解析路徑即使設定 setLenient(false),仍會接納字串內未跳脫的換行與定位字元。因此歸屬實驗不構成嚴格 JSON 合規證明。接入不可信 SSE 前需補齊拒絕規則;保留原 18 條軌跡,不當成新的線上模型實驗。
怎樣重現並檢查證據
在 hohoo-ai-lab 倉庫執行:
python demos/09-stream-session/run.py --out demos/09-stream-session/evidence/my-run --maven /path/to/mvn
python demos/09-stream-session/audit.py demos/09-stream-session/evidence/my-run已快取 Maven 依賴時可加 --offline。JDK 需為 Java 8 或兼容環境。本次實際運行使用 Java 8u171。
results.json 包含 18 條完整事件軌跡和中間狀態;manifest.json 固定運行程式碼、輸入協議與原始檔案哈希;audit.py 按事件獨立重建歷史和預覽,檢查舊回呼是否曾被接受。另有 7 組契約檢查覆蓋跨會話票據、取消、重複 ID、問答成對裁剪、收據淘汰等邊界。
現在,“一次成功回答”有了兩層條件:協議完整,且提交時仍擁有當前會話。下一步接入真實界面或服務端執行器時,應保留這兩層邊界,並補上連接取消與多行程狀態的實測,而不是繼續增加一個只判斷 HTTP 狀態碼的分支。
修復進展 · 2026-10-07
2026-10-07 修正:Demo08–10 增加 JSON 詞法校驗,三個範例各通過 53 項離線回歸;執行腳本改用 Maven 參數列表,支援含空格目錄。Demo10 稽核補上原始碼指紋檢查。原文固定版本與原始資料保持不變,修正版不是新增線上模型結果。
寫給未來的自己合上文章前,留下一點自己的理解。
你的私人學習便箋,只存在目前瀏覽器,不會上傳或公開。清理瀏覽器資料會遺失,請匯出留存。三種語言共用這篇文章的便箋。
下一篇已發布內容:Java LLM 實踐(十):點了取消,讀取執行緒為什麼還在運行? →
目前學習位置
構建 / AI 應用開發
- ○ Java LLM 實踐(八):流式回答斷了,半句答案該不該進入歷史?
- ○ Java LLM 實踐(九):取消請求後,舊回答為什麼還會覆蓋新會話?
- ○ Java LLM 實踐(十):點了取消,讀取執行緒為什麼還在運行?
繼續探索
繼續探索
接下來,走哪條路?
- 用 Java 8 呼叫大型語言模型:從第一次請求到多輪對話 →
透過三個實際執行的 Java 範例,理解模型請求、JSON 解析、讀取逾時與對話歷史。
- Java LLM 實踐(八):流式回答斷了,半句答案該不該進入歷史? →
22 個回環 HTTP 案例,驗證 UTF-8、SSE 分幀、取消與異常結束,把逐步預覽和完整歷史提交分開。
依據文章關聯與同主題已發布內容整理,不使用隨機推薦。