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:
ylhan
2026-09-16 14:13:32 +08:00
parent 4fe2755fd6
commit 4f621dc95b
6 changed files with 798 additions and 9 deletions
+79 -4
View File
@@ -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)