Enhance workflow routing and agent configuration management
- Introduce WorkflowLLMRouter for pre-response LLM routing, allowing agents to determine the appropriate function to call based on user input. - Implement UserTurnRoutingProcessor to manage user turns before reaching the LLM, ensuring proper routing and handling of user messages. - Refactor WorkflowBrain to integrate new routing logic and enhance agent stage configuration, including entry modes and resource management. - Update service factory to support dynamic LLM resource configuration based on workflow settings. - Add tests for new routing functionality and ensure proper handling of user messages in various scenarios.
This commit is contained in:
@@ -24,6 +24,7 @@ from services.pipecat.call_lifecycle import (
|
||||
)
|
||||
from services.pipecat.service_factory import (
|
||||
config_with_resource,
|
||||
create_llm,
|
||||
create_realtime_service,
|
||||
create_stt,
|
||||
create_tts,
|
||||
@@ -51,6 +52,7 @@ from pipecat.frames.frames import (
|
||||
UserImageRequestFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
||||
from pipecat.pipeline.service_switcher import ServiceSwitcher
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
@@ -487,6 +489,49 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
class UserTurnRoutingProcessor(FrameProcessor):
|
||||
"""Give a brain first right of refusal before a new user turn reaches the LLM."""
|
||||
|
||||
def __init__(self, brain: Brain):
|
||||
super().__init__()
|
||||
self._brain = brain
|
||||
self._last_user_message: dict | None = None
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if direction != FrameDirection.DOWNSTREAM or not isinstance(
|
||||
frame, LLMContextFrame
|
||||
):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
user_message = next(
|
||||
(
|
||||
message
|
||||
for message in reversed(frame.context.get_messages())
|
||||
if message.get("role") == "user"
|
||||
and isinstance(message.get("content"), str)
|
||||
and str(message.get("content") or "").strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if user_message is None:
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
if user_message is self._last_user_message:
|
||||
# Programmatic LLMRunFrame after a node transition reuses the same
|
||||
# user message. It is a response run, not another routing event.
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
self._last_user_message = user_message
|
||||
|
||||
content = str(user_message.get("content") or "").strip()
|
||||
handled = await self._brain.on_user_turn_end(content)
|
||||
if not handled:
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
||||
|
||||
@@ -589,6 +634,29 @@ def _workflow_service_switcher(
|
||||
return ServiceSwitcher(services=services), services_by_id, primary
|
||||
|
||||
|
||||
def _workflow_llm_switcher(cfg: AssistantConfig, base_service):
|
||||
"""Build an LLM switcher for the global model and Agent overrides."""
|
||||
settings = cfg.graph.get("settings") or {}
|
||||
default_id = str(settings.get("defaultLlmResourceId") or "")
|
||||
services_by_id = {}
|
||||
for resource_id, resource in cfg.workflow_model_resources.items():
|
||||
if resource.capability != "LLM":
|
||||
continue
|
||||
services_by_id[resource_id] = (
|
||||
base_service
|
||||
if resource_id == default_id
|
||||
else create_llm(config_with_resource(cfg, resource))
|
||||
)
|
||||
primary = services_by_id.get(default_id, base_service)
|
||||
services = [primary]
|
||||
services.extend(
|
||||
service for service in services_by_id.values() if service is not primary
|
||||
)
|
||||
if base_service is not primary:
|
||||
services.append(base_service)
|
||||
return LLMSwitcher(llms=services), services_by_id, primary
|
||||
|
||||
|
||||
async def run_pipeline(
|
||||
transport,
|
||||
cfg: AssistantConfig,
|
||||
@@ -630,6 +698,9 @@ async def run_pipeline(
|
||||
return
|
||||
|
||||
graph_settings = cfg.graph.get("settings") or {}
|
||||
default_llm_resource = cfg.workflow_model_resources.get(
|
||||
str(graph_settings.get("defaultLlmResourceId") or "")
|
||||
)
|
||||
default_asr_resource = cfg.workflow_model_resources.get(
|
||||
str(graph_settings.get("defaultAsrResourceId") or "")
|
||||
)
|
||||
@@ -713,7 +784,16 @@ async def run_pipeline(
|
||||
)
|
||||
input_state = {"enabled": True}
|
||||
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
|
||||
llm = brain.build_llm(cfg, context)
|
||||
llm = brain.build_llm(
|
||||
config_with_resource(cfg, default_llm_resource)
|
||||
if cfg.type == "workflow" and default_llm_resource
|
||||
else cfg,
|
||||
context,
|
||||
)
|
||||
llm_services: dict[str, FrameProcessor] = {}
|
||||
current_llm_service = llm
|
||||
if cfg.type == "workflow":
|
||||
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
|
||||
user_aggregator = LLMUserAggregator(
|
||||
context,
|
||||
params=LLMUserAggregatorParams(
|
||||
@@ -730,6 +810,7 @@ async def run_pipeline(
|
||||
),
|
||||
),
|
||||
)
|
||||
user_turn_router = UserTurnRoutingProcessor(brain)
|
||||
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
||||
text_input = TextInputProcessor(
|
||||
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
|
||||
@@ -880,6 +961,7 @@ async def run_pipeline(
|
||||
properties=vision_schema.properties,
|
||||
required=vision_schema.required,
|
||||
handler=flow_fetch_user_image,
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -913,6 +995,7 @@ async def run_pipeline(
|
||||
text_input,
|
||||
stt_processor,
|
||||
user_aggregator,
|
||||
user_turn_router,
|
||||
knowledge_retrieval,
|
||||
llm,
|
||||
# Aggregate the streamed LLM text before TTS. On interruption,
|
||||
@@ -934,24 +1017,42 @@ async def run_pipeline(
|
||||
enable_rtvi=False,
|
||||
)
|
||||
worker_holder["worker"] = worker
|
||||
default_voice_services = dict(current_voice_services)
|
||||
default_workflow_services = {
|
||||
"llm": current_llm_service,
|
||||
**current_voice_services,
|
||||
}
|
||||
|
||||
async def switch_workflow_services(
|
||||
llm_resource_id: str | None,
|
||||
asr_resource_id: str | None,
|
||||
tts_resource_id: str | None,
|
||||
) -> None:
|
||||
nonlocal current_llm_service
|
||||
requested = (
|
||||
("llm", llm_services, llm_resource_id),
|
||||
("asr", stt_services, asr_resource_id),
|
||||
("tts", tts_services, tts_resource_id),
|
||||
)
|
||||
for kind, services, resource_id in requested:
|
||||
target = services.get(resource_id) if resource_id else default_voice_services[kind]
|
||||
target = (
|
||||
services.get(resource_id)
|
||||
if resource_id
|
||||
else default_workflow_services[kind]
|
||||
)
|
||||
if target is None:
|
||||
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
|
||||
if current_voice_services[kind] is target:
|
||||
current = (
|
||||
current_llm_service
|
||||
if kind == "llm"
|
||||
else current_voice_services[kind]
|
||||
)
|
||||
if current is target:
|
||||
continue
|
||||
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
||||
current_voice_services[kind] = target
|
||||
if kind == "llm":
|
||||
current_llm_service = target
|
||||
else:
|
||||
current_voice_services[kind] = target
|
||||
await worker.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
@@ -1020,8 +1121,6 @@ async def run_pipeline(
|
||||
|
||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||
async def on_user_turn_stopped(_aggregator, _strategy, message):
|
||||
if message.content:
|
||||
brain.record_user_message(message.content)
|
||||
await queue_transcript("user", message.content, message.timestamp)
|
||||
|
||||
@assistant_aggregator.event_handler("on_assistant_text_start")
|
||||
@@ -1066,7 +1165,6 @@ async def run_pipeline(
|
||||
@text_input.event_handler("on_text_input")
|
||||
async def on_text_input(_processor, text):
|
||||
pending_text_inputs.append(text)
|
||||
brain.record_user_message(text)
|
||||
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
|
||||
await queue_transcript("user", text, time_now_iso8601())
|
||||
|
||||
|
||||
@@ -29,11 +29,18 @@ TTS_STOP_FRAME_TIMEOUT_S = 1.0
|
||||
def config_with_resource(
|
||||
cfg: AssistantConfig, resource: RuntimeModelResource
|
||||
) -> AssistantConfig:
|
||||
"""Return a call-local config view for one workflow ASR/TTS resource."""
|
||||
"""Return a call-local config view for one workflow model resource."""
|
||||
result = cfg.model_copy(deep=True)
|
||||
values = resource.values or {}
|
||||
secrets = resource.secrets or {}
|
||||
if resource.capability == "ASR":
|
||||
if resource.capability == "LLM":
|
||||
result.model = str(values.get("modelId") or "")
|
||||
result.llm_interface_type = resource.interface_type
|
||||
result.llm_values = values
|
||||
result.llm_secrets = secrets
|
||||
result.llm_api_key = str(secrets.get("apiKey") or "")
|
||||
result.llm_base_url = str(values.get("apiUrl") or "")
|
||||
elif resource.capability == "ASR":
|
||||
result.asr = str(values.get("modelId") or "")
|
||||
result.stt_language = str(values.get("language") or "")
|
||||
result.stt_interface_type = resource.interface_type
|
||||
@@ -51,7 +58,7 @@ def config_with_resource(
|
||||
result.tts_api_key = str(secrets.get("apiKey") or "")
|
||||
result.tts_base_url = str(values.get("apiUrl") or "")
|
||||
else:
|
||||
raise ValueError(f"工作流语音资源能力无效:{resource.capability}")
|
||||
raise ValueError(f"工作流模型资源能力无效:{resource.capability}")
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user