"""作业 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