- Validate stage prefixes before forwarding model tokens. Invalid prefixes may use the existing repair budget; once output starts, validation failure aborts the turn without appending a regenerated reply. Bound the token queue and close inner streams when consumption stops. - Replace delete-time lock eviction with reference-counted session locks covering holders and waiters. Serialize set/get with chat/stream/delete and release cancelled waiters without replacing a live lock. - Store person synonyms under workflow-native driver keys and expose consistent alias views to single/group reads and graph prompt injection. - Separate committed field patches from public formUpdate visibility. Commit callbacks keep repository fields synchronized even when updates are hidden or the consumer closes after graph completion. - Disable OpenAI SDK automatic retries. Own bounded retry attempts in the application, use 1/2/4 second backoff, retry streams only before text is emitted, enforce attempt timeouts, and close upstream/client resources. - Translate FastGPT SDK max_retries (total attempts) from the configured extra-retry count, and verify the real SDK loop uses four attempts. - Document the per-LLM budget and streaming failure semantics without claiming the whole multi-call HTTP turn has the same timeout. Validation: 267 offline tests passed, including actual SDK requests through MockTransport, stream repair/abort, timeout/close, lock races, aliases and formUpdate visibility; existing four dependency deprecation warnings.
231 lines
8.6 KiB
Python
231 lines
8.6 KiB
Python
"""作业 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 ["<state>1002</state>", "你", "好"]
|
||
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", need_form_update=True)
|
||
|
||
|
||
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 == "<state>1002</state>你好"
|
||
assert deltas[0].text.startswith("<state>")
|
||
|
||
|
||
@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 "<state>1002</state>你好"
|
||
|
||
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: "<state>1002</state>你好")
|
||
)
|
||
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) == "<state>1002</state>你好"
|
||
|
||
|
||
@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
|