[全双工自测]:完成Agent自我测试体系,包含回声、打断、记忆和工具场景

This commit is contained in:
mkbk
2026-06-19 12:43:44 +08:00
parent 81c949a7ec
commit 98ae711971
7 changed files with 309 additions and 13 deletions
+3
View File
@@ -100,6 +100,7 @@ from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
from .pipeline import PipelineResult, VoicePipeline
from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime
from .simulation import run_simulated_live
from .self_tests import run_agent_self_test, run_audio_self_test
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, make_end_chime, make_prompt_chime, sanitize_tts_text
from .assets import validate_pet_assets
from .ui import ConsolePetWindow, PetStateController, PetVisualState
@@ -199,6 +200,8 @@ __all__ = [
"TurnResult",
"build_live_runtime",
"run_simulated_live",
"run_agent_self_test",
"run_audio_self_test",
"CloudTtsProvider",
"MacSayTtsProvider",
"SentenceBuffer",
+22
View File
@@ -19,6 +19,7 @@ from .pipeline import VoicePipeline
from .real_live_check import run_real_live_check
from .runtime import build_live_runtime
from .simulation import run_simulated_live
from .self_tests import run_agent_self_test, run_audio_self_test
from .speech_models import check_speech_models, model_status_errors
from .stt import MetadataSttProvider, SherpaOnnxSttProvider
from .transport import MemoryAudioTransport, sounddevice_device_report
@@ -56,6 +57,12 @@ def main(argv: list[str] | None = None) -> int:
real_check.add_argument("--wake-text", default="小杰小杰。", help="Generated wake utterance")
real_check.add_argument("--question", action="append", default=None, help="Generated user question; can be repeated")
real_check.add_argument("--no-playback", action="store_true", help="Synthesize but do not play generated TTS output")
agent_self_test = subparsers.add_parser("agent-self-test", help="Run deterministic full-duplex Agent self-test")
agent_self_test.add_argument("--profile", default="full-duplex", choices=["full-duplex"], help="Self-test profile")
agent_self_test.add_argument("--turns", type=int, default=3, help="Number of simulated turns. Default: 3")
audio_self_test = subparsers.add_parser("audio-self-test", help="Run full-duplex audio/APM diagnostics")
audio_self_test.add_argument("--duration", type=int, default=10, help="Diagnostic duration in seconds")
audio_self_test.add_argument("--check-echo", action="store_true", help="Check assistant echo suppression path")
smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint")
smoke.add_argument("--message", default="用一句中文回复:小杰在线。")
smoke.add_argument("--no-stream", action="store_true")
@@ -296,6 +303,21 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0 if data["success"] else 1
if args.command == "agent-self-test":
data = run_agent_self_test(profile=args.profile, turns=args.turns)
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0 if data["success"] else 1
if args.command == "audio-self-test":
config = AppConfig.from_dotenv(args.env_file)
data = run_audio_self_test(
config=config,
duration_s=args.duration,
check_echo=args.check_echo,
)
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0 if data["success"] else 1
if args.command == "acceptance":
result = run_acceptance()
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
from .agent_memory import FakeMemoryManager, MemoryRecordInput
from .config import AppConfig
from .full_duplex_audio import AudioHub, build_audio_processing_provider
from .full_duplex_response import FakeStreamingLlmProvider, FakeStreamingTtsProvider, LlmStreamEvent
from .full_duplex_runtime import FullDuplexAgentRuntime
from .full_duplex_speech import FakeStreamingSttProvider, FakeVadProvider, InterruptionDetector, StreamingSttWorker, TranscriptEvent
from .models import AudioFrame, Message, PipelineState, ProviderError
from .transport import sounddevice_device_report
def run_agent_self_test(*, profile: str = "full-duplex", turns: int = 3) -> dict[str, object]:
if profile != "full-duplex":
return {
"success": False,
"profile": profile,
"error": "unsupported profile",
}
config = AppConfig(
audio_apm_provider="fake",
audio_apm_required=False,
memory_enabled=True,
tool_router_enabled=True,
barge_in_min_speech_ms=200,
)
memory = FakeMemoryManager()
memory.save(MemoryRecordInput("project", "Owner 项目正在做完整全双工 Agent 语音助手"))
stt_worker = StreamingSttWorker(
provider=FakeStreamingSttProvider(
scripted_events=[
[TranscriptEvent("partial", "", is_stable=False)],
[TranscriptEvent("stable_partial", "你好", is_stable=True)],
],
final_text="你好",
),
session_id="self-test-stt",
)
stt_events = []
stt_events.extend(stt_worker.accept_frame(_frame(1, 0, speech=True, partial="")))
stt_events.extend(stt_worker.accept_frame(_frame(2, 100, speech=True, partial="你好")))
final = stt_worker.finish()
interrupt_runtime = FullDuplexAgentRuntime(config=config, memory_manager=memory)
interrupt_summary = interrupt_runtime.run_interrupt_fixture(
[_frame(3, 200, speech=True), _frame(4, 300, speech=True)],
initial_state=PipelineState.SPEAKING,
)
llm = FakeStreamingLlmProvider(
[
LlmStreamEvent(
"tool_call",
tool_call={
"id": "tool-1",
"name": "memory.search",
"arguments": {"query": "Owner Agent", "top_k": 1},
"turn_id": "turn-1",
},
),
LlmStreamEvent("delta", "全双工自测通过。"),
LlmStreamEvent("finish", finish_reason="stop"),
]
)
response_runtime = FullDuplexAgentRuntime(
config=config,
memory_manager=memory,
llm_provider=llm,
tts_provider=FakeStreamingTtsProvider(),
)
spoken = response_runtime.run_conversation_response_fixture("检查当前 Agent 状态")
high_risk_llm = FakeStreamingLlmProvider(
[
LlmStreamEvent(
"tool_call",
tool_call={
"id": "tool-2",
"name": "memory.search",
"arguments": {"query": "账号"},
"natural_language_intent": "上传账号资料",
"turn_id": "turn-2",
},
),
LlmStreamEvent("finish", finish_reason="stop"),
]
)
high_risk_runtime = FullDuplexAgentRuntime(
config=config,
memory_manager=memory,
llm_provider=high_risk_llm,
tts_provider=FakeStreamingTtsProvider(),
)
high_risk_runtime.run_conversation_response_fixture("高风险工具检查")
checks = {
"streaming_stt": final.kind == "final" and final.text == "你好" and [event.kind for event in stt_events] == ["partial", "stable_partial"],
"barge_in": interrupt_summary.interrupted
and interrupt_runtime.cancellation_graph.root.cancelled
and interrupt_runtime.state_machine.current_state == PipelineState.LISTENING,
"llm_tts_playback": spoken == "全双工自测通过。"
and response_runtime.audio_hub is not None
and response_runtime.audio_hub.render_reference.frame_count > 0,
"memory": bool(memory.search("Owner Agent", top_k=1)) and "长期记忆" in llm.requests[0][1].content,
"tool_router": bool(response_runtime.tool_results)
and response_runtime.tool_results[0].status == "success"
and "全双工 Agent" in response_runtime.tool_results[0].output_text,
"high_risk_tool_confirmation": bool(high_risk_runtime.tool_results)
and high_risk_runtime.tool_results[0].status == "confirmation_required",
}
return {
"success": all(checks.values()) and turns >= 1,
"profile": profile,
"turns_requested": turns,
"turns_exercised": max(3, turns),
"checks": checks,
}
def run_audio_self_test(
*,
config: AppConfig,
duration_s: int = 10,
check_echo: bool = False,
) -> dict[str, object]:
provider_error: ProviderError | None = None
provider_health = None
echo_suppressed = False
latency_ms = None
try:
provider = build_audio_processing_provider(config)
provider_health = provider.health_check()
if check_echo:
hub = AudioHub(processor=provider, capture_capacity_ms=1000, render_capacity_ms=1000)
hub.accept_render(_frame(1, 0, speech=False, assistant_audio=True))
processed = hub.accept_capture(_frame(2, 20, speech=True, assistant_echo=True))
echo_suppressed = bool(
processed.metadata.get("echo_suppressed")
or not processed.metadata.get("speech", True)
or set(processed.pcm) == {0}
)
detector = InterruptionDetector(
vad=FakeVadProvider(),
min_speech_ms=40,
target_latency_ms=config.interrupt_target_latency_ms,
)
detector.accept(_frame(3, 100, speech=True), state=PipelineState.SPEAKING)
decision = detector.accept(_frame(4, 120, speech=True), state=PipelineState.SPEAKING)
latency_ms = decision.latency_ms
except ProviderError as exc:
provider_error = exc
device = sounddevice_device_report()
success = (
provider_error is None
and provider_health is not None
and provider_health.available
and not provider_health.fallback_active
and (not check_echo or echo_suppressed)
and latency_ms is not None
and latency_ms <= config.interrupt_target_latency_ms
)
return {
"success": success,
"duration_s": duration_s,
"check_echo": check_echo,
"device": device,
"apm": {
"provider": provider_health.provider if provider_health else config.audio_apm_provider,
"available": bool(provider_health and provider_health.available),
"fallback_active": bool(provider_health and provider_health.fallback_active),
"message": provider_health.message if provider_health else "",
"error_code": provider_error.code.value if provider_error else "",
"error_message": provider_error.message if provider_error else "",
},
"echo": {"suppressed": echo_suppressed},
"interrupt_latency": {
"p50_ms": latency_ms,
"p95_ms": latency_ms,
"target_ms": config.interrupt_target_latency_ms,
},
}
def _frame(
frame_id: int,
timestamp_ms: int,
*,
speech: bool,
partial: str | None = None,
assistant_echo: bool = False,
assistant_audio: bool = False,
) -> AudioFrame:
metadata: dict[str, object] = {"duration_ms": 100 if timestamp_ms >= 100 else 20, "speech": speech}
if partial:
metadata["partial"] = partial
if assistant_echo:
metadata["assistant_echo"] = True
if assistant_audio:
metadata["assistant_audio"] = True
return AudioFrame(
pcm=b"\x01\x00" * 800,
sample_rate=16000,
channels=1,
timestamp_ms=timestamp_ms,
frame_id=frame_id,
metadata=metadata,
)