支持流式回复与按行消息解析
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled

This commit is contained in:
FuQuan233 2026-07-30 18:08:18 +08:00
parent 0d6771eca6
commit f091497613
10 changed files with 644 additions and 45 deletions

View file

@ -0,0 +1,63 @@
# ruff: noqa: I001
import unittest
import tests.bootstrap # noqa: F401
from nonebot_plugin_llmchat.config import PresetConfig, ScopedConfig
from nonebot_plugin_llmchat.output_protocol import NO_REPLY, parse_reply_segments
from nonebot_plugin_llmchat.prompts import build_system_prompt
from nonebot_plugin_llmchat.state import StateStore
class OutputProtocolTests(unittest.TestCase):
def test_each_nonempty_text_line_is_a_message(self):
assert parse_reply_segments("第一条\n第二条\n\n第三条") == ["第一条", "第二条", "第三条"]
def test_no_reply_marker_never_becomes_a_chat_message(self):
assert parse_reply_segments(NO_REPLY) == []
assert parse_reply_segments(f"answer\n{NO_REPLY}") == ["answer"]
def test_commas_are_not_split_by_code(self):
content = "好啊,没问题,我马上来"
assert parse_reply_segments(content) == [content]
def test_fenced_code_is_one_message_without_fence_or_language(self):
content = '我来写\n```python\nprint("hello")\nprint("world")\n```\n好了'
assert parse_reply_segments(content) == [
"我来写",
'print("hello")\nprint("world")',
"好了",
]
def test_fenced_long_text_preserves_paragraphs_as_one_message(self):
content = "前言\n```text\n第一段内容。\n\n第二段内容。\n```\n结尾"
assert parse_reply_segments(content) == [
"前言",
"第一段内容。\n\n第二段内容。",
"结尾",
]
def test_unclosed_content_block_is_flushed_as_one_message(self):
content = "```text\nline 1\n\nline 3"
assert parse_reply_segments(content) == ["line 1\n\nline 3"]
def test_prompt_distinguishes_short_messages_and_atomic_content_blocks(self):
preset = PresetConfig(name="test", api_base="x", api_key="x", model_name="x")
config = ScopedConfig(api_presets=[preset], default_preset="test")
prompt = build_system_prompt(
config=config,
state=StateStore(config).group_states[1],
bot_names={"bot"},
is_group=True,
support_tools=False,
)
assert "[短消息]" in prompt
assert "一行就是一条聊天消息" in prompt
assert "[原子内容块]" in prompt
assert "不仅用于代码" in prompt
assert "需要连贯阅读的长文本,必须完整放进一个内容框" in prompt
assert "框内可以使用换行、空行和段落" in prompt
assert "不要把长文本压成一行" in prompt
assert "```text" in prompt
assert "```python" in prompt
assert NO_REPLY in prompt

254
tests/test_streaming.py Normal file
View file

@ -0,0 +1,254 @@
# ruff: noqa: I001
from types import SimpleNamespace
from typing import Any, cast
import unittest
import tests.bootstrap # noqa: F401
from nonebot_plugin_llmchat.config import PresetConfig, ScopedConfig
from nonebot_plugin_llmchat.conversation import ConversationService
from nonebot_plugin_llmchat.output_protocol import NO_REPLY
from nonebot_plugin_llmchat.state import StateStore
from nonebot_plugin_llmchat.streaming import StreamedMessageBuilder, StreamingReplyEmitter
def make_chunk(*, content=None, tool_calls=None, reasoning=None, usage=None):
if content is None and tool_calls is None and reasoning is None:
return SimpleNamespace(choices=[], usage=usage)
delta = SimpleNamespace(
content=content,
tool_calls=tool_calls,
reasoning_content=reasoning,
images=None,
)
return SimpleNamespace(choices=[SimpleNamespace(delta=delta)], usage=usage)
def tool_delta(index, *, tool_id=None, name=None, arguments=None):
function = SimpleNamespace(name=name, arguments=arguments)
return SimpleNamespace(index=index, id=tool_id, type="function", function=function)
class StreamingEmitterTests(unittest.IsolatedAsyncioTestCase):
async def test_emits_as_soon_as_a_text_line_is_complete(self):
emitted = []
async def emit(segment):
emitted.append(segment)
emitter = StreamingReplyEmitter(emit)
await emitter.feed("第一")
assert emitted == []
await emitter.feed("\n第二")
assert emitted == ["第一条"]
await emitter.feed("")
await emitter.finish()
assert emitted == ["第一条", "第二条"]
async def test_hides_tagged_reasoning_before_streaming_visible_content(self):
emitted = []
async def emit(segment):
emitted.append(segment)
emitter = StreamingReplyEmitter(emit)
await emitter.feed("<thi")
await emitter.feed("nk>secret</think>回答一\n")
assert emitted == ["回答一"]
await emitter.feed("回答二")
await emitter.finish()
assert emitted == ["回答一", "回答二"]
async def test_fenced_code_waits_for_closing_fence_and_emits_once(self):
emitted = []
async def emit(segment):
emitted.append(segment)
emitter = StreamingReplyEmitter(emit)
await emitter.feed("我来写\n```python\nprint(1)")
assert emitted == ["我来写"]
await emitter.feed("\nprint(2)\n``")
assert emitted == ["我来写"]
await emitter.feed("`")
assert emitted == ["我来写", "print(1)\nprint(2)"]
await emitter.finish()
assert emitted == ["我来写", "print(1)\nprint(2)"]
async def test_fenced_long_text_waits_for_closing_fence_and_emits_once(self):
emitted = []
async def emit(segment):
emitted.append(segment)
emitter = StreamingReplyEmitter(emit)
await emitter.feed("```text\n第一段。\n\n第二")
assert emitted == []
await emitter.feed("段。\n``")
assert emitted == []
await emitter.feed("`")
assert emitted == ["第一段。\n\n第二段。"]
await emitter.finish()
assert emitted == ["第一段。\n\n第二段。"]
async def test_no_reply_marker_is_not_emitted(self):
emitted = []
async def emit(segment):
emitted.append(segment)
emitter = StreamingReplyEmitter(emit)
await emitter.feed(NO_REPLY)
await emitter.finish()
assert emitted == []
class StreamedMessageBuilderTests(unittest.TestCase):
def test_reassembles_content_reasoning_and_tool_call_deltas(self):
builder = StreamedMessageBuilder()
first = tool_delta(0, tool_id="call-1", name="se", arguments='{"query":')
second = tool_delta(0, name="arch", arguments='"hello"}')
assert builder.add_chunk(make_chunk(content="hi", tool_calls=[first], reasoning="think ")) == "hi"
assert builder.add_chunk(make_chunk(content="!", tool_calls=[second], reasoning="more")) == "!"
usage = SimpleNamespace(total_tokens=12)
builder.add_chunk(make_chunk(usage=usage))
message = builder.build()
assert message.content == "hi!"
assert message.reasoning_content == "think more"
assert message.tool_calls[0].id == "call-1"
assert message.tool_calls[0].function.name == "search"
assert message.tool_calls[0].function.arguments == '{"query":"hello"}'
assert builder.usage.total_tokens == 12
class FakeAsyncStream:
def __init__(self, chunks):
self.chunks = chunks
def __aiter__(self):
return self._iterate()
async def _iterate(self):
for chunk in self.chunks:
yield chunk
class FakeStreamingCompletions:
def __init__(self, chunks):
self.chunks = chunks
self.calls = []
async def create(self, **kwargs):
self.calls.append(kwargs)
return FakeAsyncStream(self.chunks)
class ConversationStreamingTests(unittest.IsolatedAsyncioTestCase):
async def test_complete_streams_segments_and_returns_full_message(self):
preset = PresetConfig(name="test", api_base="x", api_key="x", model_name="x", stream=True)
config = ScopedConfig(api_presets=[preset], default_preset="test")
sent = []
async def sender(message):
sent.append(str(message))
service = ConversationService(config, StateStore(config), {"bot"}, sender)
completions = FakeStreamingCompletions(
[
make_chunk(content="第一条\n"),
make_chunk(content="二条"),
]
)
client = SimpleNamespace(chat=SimpleNamespace(completions=completions))
result = await service._complete(
cast(Any, client),
{"model": "test"},
[{"role": "user", "content": "hello"}],
stream=True,
)
assert sent == ["第一条", "第二条"]
assert result.streamed
assert result.message.content == "第一条\n第二条"
assert completions.calls[0]["stream"] is True
class SequenceStreamingCompletions:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
async def create(self, **kwargs):
self.calls.append(kwargs)
return FakeAsyncStream(self.responses.pop(0))
class RecordingMCPClient:
def __init__(self):
self.calls = []
def get_friendly_name(self, name):
return name
async def call_tool(self, name, arguments, **kwargs):
self.calls.append((name, arguments, kwargs))
return "tool result"
class StreamingToolLoopTests(unittest.IsolatedAsyncioTestCase):
async def test_streaming_tool_call_is_reassembled_before_final_streamed_reply(self):
preset = PresetConfig(
name="test",
api_base="x",
api_key="x",
model_name="x",
stream=True,
support_mcp=True,
)
config = ScopedConfig(api_presets=[preset], default_preset="test")
sent = []
async def sender(message):
sent.append(str(message))
service = ConversationService(config, StateStore(config), {"bot"}, sender)
first_tool_delta = tool_delta(
0,
tool_id="call-1",
name="mcp__demo__search",
arguments='{"query":',
)
second_tool_delta = tool_delta(0, arguments='"hello"}')
completions = SequenceStreamingCompletions(
[
[
make_chunk(tool_calls=[first_tool_delta]),
make_chunk(tool_calls=[second_tool_delta]),
],
[
make_chunk(content="查到了\n"),
make_chunk(content="结果如下"),
],
]
)
client = SimpleNamespace(chat=SimpleNamespace(completions=completions))
mcp_client = RecordingMCPClient()
transcript = [{"role": "user", "content": "search"}]
completion = await service._run_tool_loop(
client=cast(Any, client),
preset=preset,
mcp_client=cast(Any, mcp_client),
request_config={"model": "test", "tools": [{}]},
messages=[{"role": "system", "content": "system"}],
transcript=transcript,
event=cast(Any, SimpleNamespace(self_id=1)),
is_group=False,
)
assert completion.streamed
assert completion.message.content == "查到了\n结果如下"
assert mcp_client.calls[0][0] == "mcp__demo__search"
assert mcp_client.calls[0][1] == {"query": "hello"}
assert sent == ["正在使用mcp__demo__search", "查到了", "结果如下"]
assert len(completions.calls) == 2
assert all(call["stream"] is True for call in completions.calls)

View file

@ -83,7 +83,7 @@ class ToolLoopTests(unittest.IsolatedAsyncioTestCase):
client = FakeClient()
mcp_client = FakeMCPClient()
transcript = [{"role": "user", "content": "hello"}]
message = await service._run_tool_loop(
completion = await service._run_tool_loop(
client=cast(Any, client),
preset=preset,
mcp_client=cast(Any, mcp_client),
@ -94,7 +94,7 @@ class ToolLoopTests(unittest.IsolatedAsyncioTestCase):
is_group=False,
)
assert message.content == "final answer"
assert completion.message.content == "final answer"
assert mcp_client.call_count == 2
assert len(client.chat.completions.calls) == 5
assert "tools" not in client.chat.completions.calls[-1]