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.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import asyncio
|
||||
from contextlib import aclosing
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent.collect import validated_reply
|
||||
from src.agent.llm import LLMTimeoutError, OpenAILLMService
|
||||
from src.backends.chat import ChatInput, TextDelta
|
||||
from src.backends.langgraph import LangGraphBackend
|
||||
from src.services.app_service import AccidentAppService
|
||||
from src.services.repository import InMemorySessionRepository
|
||||
|
||||
|
||||
class Graph:
|
||||
async def ainvoke(self, data, *, config, context):
|
||||
raw, _ = await validated_reply(context.llm, 'system', 'user', '1002')
|
||||
return {'response_text': raw, 'turn_patch': {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_prefix_reaches_client_before_model_finishes():
|
||||
release = asyncio.Event()
|
||||
finished = asyncio.Event()
|
||||
class LLM:
|
||||
async def stream_tokens(self, *args, **kwargs):
|
||||
yield '<sta'
|
||||
yield 'te>1002</state>'
|
||||
await release.wait()
|
||||
yield 'tail'
|
||||
finished.set()
|
||||
backend = LangGraphBackend(graph=Graph(), llm=LLM())
|
||||
async with aclosing(backend.stream(ChatInput('s', 'hello'))) as events:
|
||||
first = await asyncio.wait_for(anext(events), 1)
|
||||
assert first == TextDelta('<state>1002</state>')
|
||||
assert not finished.is_set()
|
||||
release.set()
|
||||
remaining = [e async for e in events]
|
||||
assert ''.join(e.text for e in remaining) == 'tail'
|
||||
assert finished.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closing_application_stream_cancels_model_before_unlocking():
|
||||
cancelled = asyncio.Event()
|
||||
class LLM:
|
||||
async def stream_tokens(self, *args, **kwargs):
|
||||
yield '<state>1002</state>'
|
||||
try:
|
||||
await asyncio.Future()
|
||||
finally:
|
||||
cancelled.set()
|
||||
service = AccidentAppService(LangGraphBackend(graph=Graph(), llm=LLM()), InMemorySessionRepository())
|
||||
async with aclosing(service.stream(ChatInput('s', 'hello'))) as events:
|
||||
await asyncio.wait_for(anext(events), 1)
|
||||
assert cancelled.is_set()
|
||||
assert not service._locks._entries
|
||||
assert await service.set_info('s', 'hphm1', 'test') == ('200', '')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('stream', [False, True])
|
||||
async def test_timeout_covers_whole_attempt_and_retries_are_bounded(stream):
|
||||
responses = []
|
||||
class HangingResponse:
|
||||
closed = False
|
||||
def __aiter__(self): return self.generate()
|
||||
async def generate(self):
|
||||
await asyncio.Future()
|
||||
yield None
|
||||
async def close(self): self.closed = True
|
||||
async def create(**kwargs):
|
||||
if stream:
|
||||
response = HangingResponse()
|
||||
responses.append(response)
|
||||
return response
|
||||
await asyncio.Future()
|
||||
llm = OpenAILLMService(model='m', api_key='audit', timeout_seconds=0.01, max_retries=2)
|
||||
call = AsyncMock(side_effect=create)
|
||||
llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=call)))
|
||||
llm._retry_delay = AsyncMock()
|
||||
# 先加载 SDK 错误类;外层看门狗不测模块惰性导入耗时。
|
||||
assert llm._retryable(TimeoutError())
|
||||
assert isinstance(llm._error(TimeoutError()), LLMTimeoutError)
|
||||
async with asyncio.timeout(10):
|
||||
with pytest.raises(LLMTimeoutError):
|
||||
if stream:
|
||||
async for _ in llm.stream_tokens('system', 'user'): pass
|
||||
else:
|
||||
await llm.complete('system', 'user')
|
||||
assert call.await_count == 3
|
||||
assert llm._retry_delay.await_count == 2
|
||||
assert all(response.closed for response in responses)
|
||||
Reference in New Issue
Block a user