Phase 3:Flows 接管网页文字路径(默认仍 legacy)
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 3 完成。 **语音路径保持 legacy**,文字路径可由 AI_AGENT_RUNTIME=flows 切换。 服务器实测通过工具、姓名、记忆三项验收,随后按文档要求改回 legacy。 新增 app/agent/flows_runtime.py - FlowsBridge:LLM 文本帧 → RTVI 消息 + 分句 TTS,产出与 legacy 的 _run_turn 一致(bot-llm-started → 逐 token bot-llm-text → stopped → bot-transcription / bot-output) - UserTextRecorder:截下用户原话供策略层判定语义等级 - build_flows_parts / make_flow_manager:拆成两段,解决 "FlowManager 要 worker、worker 要等管线" 的先后依赖 - 节点只用 task_messages,**不用 role_message**(避免两条 system 消息) - respond_immediately=False(不自动开口) - handler 用闭包捕获 ctx_factory,不用模块级全局(并发会话会串身份) 新增 app/agent/llm_service.py - 固化 Phase 1 spike 的选型:复用 DeepSeekLLMService,显式覆盖模型名 (Pipecat 默认的 deepseek-v4-flash 不存在) 配置 app/config.py - 新增 AgentConfig + AI_AGENT_RUNTIME,**默认 legacy** 改造 services/bot_server.py - EngineProcessor 加 owns_text:flows 模式下语音仍归它,文字帧往下传 - runtime_deps() 暴露 memory/identity/session/tts,两条路径共用同一份资源 (避免各建一份 SQLite 连接或各加载一次 TTS) - **_run_task 改用 PipelineRunner**:直接 task.run() 时 runner 没挂上, Flows 的工具执行会失败("runner is not set; call attach() first"), 异常被转成给 LLM 的通用错误串,默认日志看不到。这正是 Phase 1 spike 记录的坑 2,集成时又踩了一次。 新增 app/tests/test_flows_runtime.py(22 项,不依赖网络) 新增 deploy.sh - **不用 --delete**、一次只同步一个目录、显式排除清单。 起因见下。 客户端 client/test_client.py 加 --text 模式(RTVI send-text) - 信封字段实测校正两次:**是 id 不是 msg_id**,**data.content 不是 text**; 写错会被服务端以 "Invalid RTVI transport message" 拒掉且客户端无感知 - 等待条件改为"等到非静音音频",不能数音频块——WebRTC 空闲时也在推静音 服务器实测(2026-09-16) - legacy 文字:11.2s 音频 / 25 个有声段 - flows 文字:9.3s 音频 / 18 个有声段,首音约 2.8s - flows 记忆闭环:只说"记住我不吃香菜" → 工具写入 confirmed 记忆 → 数据库确认 #4 [confirmed] preference 饮食偏好=不吃香菜 - 称呼"林"从档案恢复 已知问题(留给 Phase 4/7) - Pipecat 报 "Both system_instruction and an initial system message in context are set" 警告。来源未查明(我只用了 task_messages)。 切回本地 vLLM 时可能 400,因为该后端拒绝多条 system 消息。 - Flows 路径暂不暴露 web_search(交接文档要求分阶段,搜索仍走 legacy)
This commit is contained in:
+79
-4
@@ -59,6 +59,7 @@ from pipecat.frames.frames import (
|
||||
TranscriptionFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
@@ -75,6 +76,10 @@ from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
|
||||
from pipecat.utils.asyncio.task_manager import TaskManager
|
||||
from pipecat.workers.base_worker import WorkerParams
|
||||
|
||||
from app.agent.flows_runtime import (
|
||||
UserTextRecorder, build_flows_parts, initialize_conversation_node,
|
||||
make_flow_manager,
|
||||
)
|
||||
from app.config import AppConfig
|
||||
from app.engine import ConversationEngine, EventKind
|
||||
from app.identity import ManualIdentityProvider
|
||||
@@ -199,11 +204,17 @@ class EngineProcessor(FrameProcessor):
|
||||
cfg: AppConfig,
|
||||
transport: SmallWebRTCTransport,
|
||||
rtvi: RTVIProcessor,
|
||||
*,
|
||||
owns_text: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
self.transport = transport
|
||||
self.rtvi = rtvi
|
||||
#: True = 文字路径也归本处理器(legacy)
|
||||
#: False = 文字帧往下传给 Flows 处理器(Phase 3 的 flows 运行时)
|
||||
#: 语音路径**始终**归本处理器——交接文档要求语音暂不迁移。
|
||||
self.owns_text = owns_text
|
||||
self._vad = SimpleVAD()
|
||||
self._pre_roll: deque[bytes] = deque(maxlen=50) # 约 1 秒
|
||||
self._buf = bytearray() # 当前话语的 PCM16
|
||||
@@ -240,6 +251,15 @@ class EngineProcessor(FrameProcessor):
|
||||
self._engine.stt = StreamingSTT(self.cfg.voice)
|
||||
return self._engine
|
||||
|
||||
async def runtime_deps(self):
|
||||
"""暴露 memory/identity/session/tts,供 Flows 路径复用。
|
||||
|
||||
资源只在一个地方创建(`_ensure_engine`),避免两条路径各建一份
|
||||
SQLite 连接或各加载一次 TTS——那会立刻导致"两条路径看到不同数据"。
|
||||
"""
|
||||
engine = await self._ensure_engine()
|
||||
return self._memory, engine.identity, self._session, engine.tts
|
||||
|
||||
async def setup(self, setup):
|
||||
await super().setup(setup)
|
||||
log.info("会话处理器已就绪")
|
||||
@@ -255,10 +275,17 @@ class EngineProcessor(FrameProcessor):
|
||||
|
||||
elif isinstance(frame, LLMConfigureOutputFrame):
|
||||
# RTVI 在 send-text 前后用这个帧表达 audio_response 选项。
|
||||
# 即使在 flows 模式下也记录一份(语音路径仍归本处理器),
|
||||
# 同时往下传,让 FlowsBridge 也能看到。
|
||||
self._skip_tts = frame.skip_tts
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
elif isinstance(frame, LLMMessagesAppendFrame):
|
||||
if not self.owns_text:
|
||||
# flows 运行时:文字交给下游的 Flows 处理器,
|
||||
# 这里只放行,不消费。
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
text = extract_user_text(frame.messages)
|
||||
if text and frame.run_llm is not False:
|
||||
log.info("收到网页文字输入: %d 字", len(text))
|
||||
@@ -506,8 +533,35 @@ async def offer(request: SmallWebRTCRequest):
|
||||
),
|
||||
)
|
||||
rtvi = RTVIProcessor()
|
||||
engine_proc = EngineProcessor(_cfg, transport, rtvi)
|
||||
pipeline = Pipeline([transport.input(), engine_proc, transport.output()])
|
||||
# Phase 3:文字路径可选走 Flows,**语音始终归 EngineProcessor**
|
||||
# (交接文档要求语音暂不迁移)。
|
||||
# 默认 legacy;只有显式设 AI_AGENT_RUNTIME=flows 才会走新路径。
|
||||
use_flows = _cfg.agent.use_flows
|
||||
engine_proc = EngineProcessor(_cfg, transport, rtvi, owns_text=not use_flows)
|
||||
|
||||
flows_objs = None
|
||||
if use_flows:
|
||||
# 复用 EngineProcessor 已建好的资源,不另建一份
|
||||
memory, identity, session_id, tts = await engine_proc.runtime_deps()
|
||||
llm, aggregator, bridge, ctx_factory, tool_schemas = \
|
||||
await build_flows_parts(
|
||||
cfg=_cfg, memory=memory, identity=identity,
|
||||
session_id=session_id, tts=tts, rtvi=rtvi)
|
||||
pipeline = Pipeline([
|
||||
transport.input(),
|
||||
engine_proc, # 只吃语音
|
||||
UserTextRecorder(extract_user_text), # 记下用户原话供策略层
|
||||
aggregator.user(), # 收文字
|
||||
llm, # Flows 驱动
|
||||
bridge, # 文本 → RTVI + TTS
|
||||
aggregator.assistant(),
|
||||
transport.output(),
|
||||
])
|
||||
flows_objs = (llm, aggregator, ctx_factory, tool_schemas)
|
||||
log.info("Agent 运行时: flows(文字路径),语音仍走 legacy")
|
||||
else:
|
||||
pipeline = Pipeline([transport.input(), engine_proc, transport.output()])
|
||||
log.info("Agent 运行时: legacy(默认)")
|
||||
# 注意:这个版本里 PipelineParams 没有 allow_interruptions——
|
||||
# 打断在 1.10 已移到 pipecat/turns/ 框架。实验阶段我们不用它的
|
||||
# 轮次框架(方案 §5 的边界决定是"打断由客户端判定"),
|
||||
@@ -568,12 +622,33 @@ async def offer(request: SmallWebRTCRequest):
|
||||
async def _run_task():
|
||||
log.info("PipelineWorker 开始运行")
|
||||
try:
|
||||
# TaskManager 必须在这里建——模块级没有运行中的事件循环
|
||||
await task.run(WorkerParams(task_manager=TaskManager()))
|
||||
# **必须用 PipelineRunner,不能直接 task.run(...)。**
|
||||
# 直接 run 时 runner 没挂上,Pipecat 自己的函数调用机制会失败:
|
||||
# "Worker 'PipelineWorker#N': runner is not set; call attach() first."
|
||||
# 症状是工具被调用但 handler 从不执行,LLM 只收到一句通用的
|
||||
# "The function `X` failed and returned no result.",默认日志看不到。
|
||||
# legacy 路径不受影响(它的工具循环在自有引擎里),
|
||||
# 但 Flows 路径的工具走 Pipecat 机制,必须 attach。
|
||||
# 见笔记库 P1-Flows兼容性Spike结论.md 坑 2。
|
||||
await PipelineRunner(handle_sigint=False).run(task)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("PipelineWorker 异常退出: %s", e)
|
||||
log.info("PipelineWorker 已结束")
|
||||
|
||||
if flows_objs is not None:
|
||||
llm, aggregator, ctx_factory, tool_schemas = flows_objs
|
||||
flow = make_flow_manager(worker=task, llm=llm, aggregator=aggregator)
|
||||
identity = engine_proc._engine.identity if engine_proc._engine else None
|
||||
display_name = ""
|
||||
if identity is not None:
|
||||
display_name = getattr(identity.current(), "display_name", "") or ""
|
||||
# **必须 await**:initialize 是协程但签名显示 -> None,
|
||||
# 漏 await 不报错,只是人格/任务/工具全部静默失效(Phase 1 栽过)。
|
||||
await initialize_conversation_node(
|
||||
flow, display_name=display_name, tool_schemas=tool_schemas,
|
||||
ctx_factory=ctx_factory)
|
||||
log.info("Flows 节点已初始化:工具 %d 个", len(tool_schemas))
|
||||
|
||||
bg = asyncio.create_task(_run_task())
|
||||
_bg_tasks.add(bg)
|
||||
bg.add_done_callback(_bg_tasks.discard)
|
||||
|
||||
Reference in New Issue
Block a user