Files
ZNJJ-api-server/test/backends/test_langgraph_streaming.py
T
Eric Wang 24e2cfbe10 fix(backends): make streamed replies and session state consistent
- 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.
2026-10-23 15:54:51 +08:00

231 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""作业 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