"""作业 8 真实增量流式测试:模型 token 逐条透出到 SSE 事件。 验证点: - 对话调用的 token 逐条到达(不是整段切片); - 字段提取辅助调用(【字段提取】标记)不推流; - 图完成后产出 FormUpdate(差量); - 图任务异常(LLM 鉴权/限流)在流路径正确映射; - 客户端断开时图任务被取消(停止 LLM 调用); - 无 stream_tokens 的 LLM(fake 兼容路径)退化为整段推送。 """ import asyncio from types import SimpleNamespace import pytest from src.agent.llm import ( LLMAuthError, LLMRateLimitError, LLMService, ) from src.backends.chat import ( ChatBackendAuthenticationError, ChatBackendRateLimitError, ChatInput, FormUpdate, TextDelta, ) from src.backends.langgraph import LangGraphBackend from src.agent.graph import build_graph class _Event: def __init__(self, content): self.choices = [SimpleNamespace(delta=SimpleNamespace(content=content))] class StreamingFakeLLM: """带 stream_tokens 的测试 LLM:对话逐 token,提取返回 JSON。""" def __init__(self, reply_tokens=None, extraction_json="{}"): self._reply_tokens = reply_tokens or ["1002", "你", "好"] self._extraction_json = extraction_json self.complete_calls = [] # inner.complete 实际收到的调用 self.streamed_sessions = [] # 推过流的 system_prompt async def complete(self, system_prompt, user_message, *, history=None): self.complete_calls.append(system_prompt) if "【字段提取】" in system_prompt: return self._extraction_json return "".join(self._reply_tokens) async def stream_tokens(self, system_prompt, user_message, *, history=None): self.streamed_sessions.append(system_prompt) for token in self._reply_tokens: await asyncio.sleep(0) # 让出调度,模拟真实流式 yield token def make_input(session="stream-s1", text="继续办理"): return ChatInput(session_id=session, text=text, client_mode="direct") async def _second_turn_events(backend, session="stream-s1"): """先完成 welcome 轮(固定应答、无 LLM 调用),再收集第二轮的流事件。""" await backend.complete(make_input(session, "新对话")) return [ event async for event in backend.stream(make_input(session, "继续办理")) ] @pytest.mark.asyncio async def test_stream_emits_tokens_incrementally(): """对话 token 逐条到达(多片段、非整段),且 state 前缀在流中。 首轮 welcome 是固定应答(无 LLM 调用),走整段回退; 第二轮进入采集、LLM 被调用,才是真实增量路径。 """ backend = LangGraphBackend(llm=StreamingFakeLLM()) events = await _second_turn_events(backend) deltas = [event for event in events if isinstance(event, TextDelta)] # 逐 token:至少 3 个片段(旧实现只有 2 个:前缀+正文) assert len(deltas) >= 3 joined = "".join(event.text for event in deltas) assert joined == "1002你好" assert deltas[0].text.startswith("") @pytest.mark.asyncio async def test_extraction_call_does_not_stream(): """字段提取调用(【字段提取】标记)走 inner.complete,不进客户端流。""" llm = StreamingFakeLLM(extraction_json='{"has_injury": false}') backend = LangGraphBackend(llm=llm) events = await _second_turn_events(backend) streamed = "".join( event.text for event in events if isinstance(event, TextDelta) ) assert "has_injury" not in streamed # 提取 JSON 不漏出 assert "【字段提取】" not in streamed assert any("【字段提取】" in call for call in llm.complete_calls) # 提取标记的调用没有走 stream_tokens assert all( "【字段提取】" not in prompt for prompt in llm.streamed_sessions ) @pytest.mark.asyncio async def test_form_update_emitted_after_stream(): """差量(turn_patch)在文本流之后产出。""" llm = StreamingFakeLLM(extraction_json='{"has_injury": false, "vehicle_count": 2}') backend = LangGraphBackend(llm=llm) events = await _second_turn_events(backend) kinds = [type(event).__name__ for event in events] assert kinds[-1] == "FormUpdate" update = events[-1].data assert update.get("has_injury") is False @pytest.mark.asyncio async def test_stream_maps_llm_errors(): class _Failing(LLMService): async def complete(self, *a, **k): raise AssertionError("不应走批处理") async def stream_tokens(self, *a, **k): raise LLMAuthError("bad key") yield # pragma: no cover - 保持生成器形态 backend = LangGraphBackend(llm=_Failing()) await backend.complete(make_input("err-s1", "新对话")) # welcome 轮 with pytest.raises(ChatBackendAuthenticationError): async for _ in backend.stream(make_input("err-s1", "继续办理")): pass class _Failing429(LLMService): async def complete(self, *a, **k): raise AssertionError("不应走批处理") async def stream_tokens(self, *a, **k): raise LLMRateLimitError("限流") yield backend = LangGraphBackend(llm=_Failing429()) await backend.complete(make_input("err-s2", "新对话")) with pytest.raises(ChatBackendRateLimitError): async for _ in backend.stream(make_input("err-s2", "继续办理")): pass @pytest.mark.asyncio async def test_client_disconnect_cancels_graph_task(): """生成器被关闭后,图任务(LLM 调用)随之取消。 首轮 welcome 是固定应答、不调 LLM;先完成一轮,再对第二轮 (进入采集、会调 LLM)断开。 """ started = asyncio.Event() cancelled = asyncio.Event() class _SlowLLM: async def complete(self, system_prompt, user_message, *, history=None): return "1002你好" async def stream_tokens(self, system_prompt, user_message, *, history=None): started.set() try: await asyncio.sleep(60) yield "不应到达" except asyncio.CancelledError: cancelled.set() raise backend = LangGraphBackend(llm=_SlowLLM()) await backend.complete(make_input("disc-s1", "新对话")) # welcome 轮 generator = backend.stream(make_input("disc-s1", "继续办理")) # 启动消费(endpoint 开始读取 SSE),等 LLM 流式开始 pending = asyncio.ensure_future(generator.__anext__()) await asyncio.wait_for(started.wait(), timeout=5) # 模拟客户端断开:取消挂起的读取 -> 生成器 finally 取消图任务 pending.cancel() await asyncio.wait_for(cancelled.wait(), timeout=5) @pytest.mark.asyncio async def test_llm_without_stream_tokens_falls_back_to_single_chunk(): """无 stream_tokens 的 LLM(fake 兼容)退化为整段推送,语义不炸。""" from src.agent.llm import FakeLLMService backend = LangGraphBackend( llm=FakeLLMService(lambda s, u: "1002你好") ) events = await _second_turn_events(backend, "fb-s1") deltas = [event for event in events if isinstance(event, TextDelta)] assert "".join(event.text for event in deltas) == "1002你好" @pytest.mark.asyncio async def test_streaming_backend_through_endpoint_sse_protocol(): """真实流式 backend 过 endpoint SSE 协议层:token 级分片被正确重组, stage_code 事件先于正文(API 层缓冲解析对任意分片的兼容性验证)。 """ from src.api.endpoints import chat from src.schemas.models import ProcessRequest_chat backend = LangGraphBackend(llm=StreamingFakeLLM()) await backend.complete(make_input("e2e-s1", "新对话")) # welcome 轮 response = await chat( ProcessRequest_chat( sessionId="e2e-s1", timeStamp="20261022120000", text="继续办理", clientMode="direct", ), stream=True, backend=backend, ) chunks = [] async for chunk in response.body_iterator: chunks.append(chunk.decode() if isinstance(chunk, bytes) else chunk) body = "".join(chunks) assert body.index("event: stage_code") < body.index("event: text_delta") assert '"nextStageCode": "1002"' in body # token 级流式:"你"、"好" 作为独立 text_delta 事件逐条到达 assert body.count("event: text_delta") == 2 assert '{"text": "你"}' in body assert '{"text": "好"}' in body assert "event: done" in body