Files
ai-companion/app/agent/flows_runtime.py
T
ylhan 4f621dc95b 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)
2026-09-16 14:13:32 +08:00

351 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pipecat Flows 运行时装配(Phase 3)。
**只接管网页文字路径。** 语音仍走 legacy 的 `EngineProcessor`——交接文档
明确要求 "语音路径暂时保持 legacy",等 Phase 3、4 稳定后再迁。
管线形态:
transport.input()
→ EngineProcessor(owns_text=False) 语音仍由它处理;文字帧往下传
→ flows 的 context aggregator.user()
→ DeepSeekLLMService Flows 驱动
→ FlowsBridge LLM 文本 → RTVI 消息 + 分句 TTS
→ flows 的 context aggregator.assistant()
→ transport.output()
两个关键设计:
* **只用 `task_messages`,不用 `role_message`。** Phase 1 spike 实测:
两者会各产生一条 system 消息进请求。只用一种。这里把人格与任务都写进
`task_messages`,与 legacy"每轮重建单条 system"的做法一致。
* **工具执行复用 `app/agent/tools.py`**,不另写一套。身份由服务端注入,
模型不得传。
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from typing import Any, AsyncIterator, Callable
import numpy as np
from pipecat.frames.frames import (
Frame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
TextFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from ..config import AppConfig
from ..memory.base import MemoryStore
from ..prompts import PERSONA_RULES
from ..voice.tts import split_sentences
from . import tools as agent_tools
log = logging.getLogger(__name__)
# ---------------------------------------------------------------- 本轮用户原话
#: 本轮用户原话。策略层要它区分"用户明确要求记住"和"模型自己推断",
#: 而 Flows 的工具 handler 只看得到工具参数,看不到原话。
#: 用单元素列表做可变闭包——handler 是闭包,拿不到外层的非局部变量。
#:
#: 注意:这是**单用户在用的原型**。多用户并发会话时这里会互相覆盖,
#: 要支持并发得改成按 session 分键(Phase 5 迁移语音路径时一并处理)。
_LAST_USER_TEXT: list[str] = [""]
def set_last_user_text(text: str) -> None:
_LAST_USER_TEXT[0] = text or ""
# ---------------------------------------------------------------- 节点
#: 会话控制状态。**不要把长期记忆复制进来**——Flows 的 state 不是数据库,
#: 长期数据一律走 MemoryStore(交接文档 §七)。
def _conversation_node(*, display_name: str, tool_schemas: list[dict],
ctx_factory: Callable[[], agent_tools.ToolContext]) -> dict:
"""默认节点:普通陪伴对话 + 可用工具。
人格与任务都放在 task_messages 里——**不用 role_message**,
避免两条 system 消息(见模块文档)。
"""
persona = PERSONA_RULES
if display_name:
persona += f"\n\n当前用户希望被称为:{display_name}。"
from pipecat.flows import FlowsFunctionSchema
functions = []
for schema in tool_schemas:
fn = schema["function"]
functions.append(FlowsFunctionSchema(
name=fn["name"],
description=fn["description"],
properties=fn["parameters"].get("properties", {}),
required=fn["parameters"].get("required", []),
handler=_make_handler(fn["name"], ctx_factory),
))
return {
"name": "conversation",
"task_messages": [
{"role": "system", "content": persona},
],
"functions": functions,
# 进入节点不自动开口:文字路径要等用户先说话。
# (spike 实测默认 True 会在 initialize 时立刻触发一轮回复,
# 那条回复与用户输入无关,会污染归因,也会让网页莫名多一段话。)
"respond_immediately": False,
}
def _make_handler(name: str, ctx_factory: Callable[[], agent_tools.ToolContext]):
"""把工具层的执行器包成 Flows 的 handler。
**用闭包捕获 ctx_factory,不用模块级全局**——全局在并发连接时会串
(两个会话各自的 owner_user_id 会互相覆盖)。
"""
async def handler(args, flow_manager=None):
outcome = await agent_tools.execute(name, dict(args or {}), ctx_factory())
return outcome.content
return handler
# ---------------------------------------------------------------- 输入记录
class UserTextRecorder(FrameProcessor):
"""记录本轮用户原话,供策略层判定语义等级。
策略层要区分"用户明确要求记住"和"模型自己推断",依据是**用户原话**,
而 Flows 的工具 handler 只看得到工具参数。所以这里把原话截下来。
"""
def __init__(self, extractor):
super().__init__()
self._extract = extractor
async def process_frame(self, frame: Frame, direction: FrameDirection):
from pipecat.frames.frames import LLMMessagesAppendFrame
await super().process_frame(frame, direction)
if isinstance(frame, LLMMessagesAppendFrame):
try:
text = self._extract(frame.messages)
if text:
set_last_user_text(text)
except Exception: # noqa: BLE001 — 记录失败不该影响对话
log.debug("记录用户原话失败", exc_info=True)
await self.push_frame(frame, direction)
# ---------------------------------------------------------------- 输出桥
class FlowsBridge(FrameProcessor):
"""把 Flows/LLM 的文本帧转成 RTVI 消息 + 分句 TTS。
产出**与 legacy 的 `_run_turn` 保持一致**(网页依赖这些消息渲染):
bot-llm-started → 逐 token 的 bot-llm-text → bot-llm-stopped
→ bot-transcription / bot-output(完整文本)
"""
def __init__(self, *, rtvi, tts, cfg: AppConfig, audio_enabled: bool = True):
super().__init__()
self.rtvi = rtvi
self.tts = tts
self.cfg = cfg
self.audio_enabled = audio_enabled
self._buffer = ""
self._reply: list[str] = []
self._started = False
self._speak_tasks: list[asyncio.Task] = []
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, LLMFullResponseStartFrame):
self._started = True
self._buffer = ""
self._reply = []
await self.push_frame(frame, direction)
await self.rtvi.push_transport_message(_rtvi_bot_started())
elif isinstance(frame, TextFrame) and frame.text:
self._buffer += frame.text
self._reply.append(frame.text)
# 逐 token 给网页(Playground 的 Conversation 视图需要)
await self.rtvi.push_transport_message(_rtvi_bot_text(frame.text))
await self.push_frame(frame, direction)
# 攒到句子边界就送去合成——这是提前出声的唯一手段
segs = split_sentences(self._buffer)
if len(segs) > 1:
*ready, self._buffer = segs
for seg in ready:
await self._speak(seg)
elif isinstance(frame, LLMFullResponseEndFrame):
if self._buffer.strip():
await self._speak(self._buffer)
self._buffer = ""
reply = "".join(self._reply).strip()
if reply:
# 完整文本单发一次:Playground 1.7 的 Conversation 视图
# 主要消费完整转写/bot-output,只给 token 级不会稳定渲染。
await self.rtvi.push_transport_message(_rtvi_bot_transcription(reply))
await self.rtvi.push_transport_message(
_rtvi_bot_output(reply, spoken=self.audio_enabled))
if self._started:
await self.rtvi.push_transport_message(_rtvi_bot_stopped())
self._started = False
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
async def _speak(self, text: str) -> None:
text = (text or "").strip()
if not text or not self.audio_enabled:
return
try:
audio = await asyncio.to_thread(self.tts.synthesize, text)
except Exception as e: # noqa: BLE001 — TTS 失败不该中断文字显示
log.warning("Flows 路径 TTS 失败(文字仍显示):%s", e)
return
if audio is None or not len(audio):
return
await self._push_audio(np.asarray(audio, dtype=np.float32),
getattr(self.tts, "sample_rate", 24000))
async def _push_audio(self, samples: np.ndarray, sample_rate: int) -> None:
from pipecat.frames.frames import OutputAudioRawFrame
out_rate = self.cfg.voice.output_sample_rate if hasattr(
self.cfg.voice, "output_sample_rate") else 24000
if sample_rate != out_rate:
n = int(len(samples) * out_rate / sample_rate)
samples = np.interp(np.linspace(0, len(samples) - 1, n),
np.arange(len(samples)), samples).astype(np.float32)
pcm = (np.clip(samples, -1, 1) * 32767).astype(np.int16)
step = out_rate // 50 # 20ms 一块
for i in range(0, len(pcm), step):
await self.push_frame(OutputAudioRawFrame(
audio=pcm[i:i + step].tobytes(), sample_rate=out_rate,
num_channels=1))
# ---------------------------------------------------------------- RTVI 消息
def _rtvi_bot_started():
import pipecat.processors.frameworks.rtvi.models as R
return R.BotLLMStartedMessage()
def _rtvi_bot_stopped():
import pipecat.processors.frameworks.rtvi.models as R
return R.BotLLMStoppedMessage()
def _rtvi_bot_text(text: str):
import pipecat.processors.frameworks.rtvi.models as R
return R.BotLLMTextMessage(data=R.TextMessageData(text=text))
def _rtvi_bot_transcription(text: str):
import pipecat.processors.frameworks.rtvi.models as R
return R.BotTranscriptionMessage(data=R.TextMessageData(text=text))
def _rtvi_bot_output(text: str, *, spoken: bool):
import pipecat.processors.frameworks.rtvi.models as R
return R.BotOutputMessage(data=R.BotOutputMessageData(
text=text, aggregated_by="sentence", spoken=spoken))
# ---------------------------------------------------------------- 装配
async def build_flows_parts(
*,
cfg: AppConfig,
memory: MemoryStore,
identity,
session_id: str,
tts,
rtvi,
audio_enabled: bool = True,
):
"""造出 Flows 路径需要的**零件**,不含 FlowManager。
为什么不在这里建 FlowManager:它要求传 `worker`(PipelineWorker),
而 worker 又必须在管线建好之后才能建,是个先后依赖。
所以顺序是:零件 → 管线 → worker → FlowManager → initialize。
返回 (llm, aggregator, bridge, ctx_factory, tool_schemas)
"""
from .llm_service import create_llm_service
llm = create_llm_service(cfg.llm)
if llm is None:
raise RuntimeError(
f"Flows 路径不支持后端 {cfg.llm.name!r}(只支持 deepseek)。"
f"请把 AI_AGENT_RUNTIME 改回 legacy。")
context = LLMContext()
aggregator = LLMContextAggregatorPair(context)
def ctx_factory() -> agent_tools.ToolContext:
"""每次工具调用现算身份——用户可能在会话中途切换。"""
d = identity.current()
return agent_tools.ToolContext(
owner_user_id=d.user_id, # ← 服务端注入,模型不得传
session_id=session_id,
turn_id=f"t_{datetime.now(UTC).strftime('%H%M%S%f')}",
identity_confirmed=d.confirmed,
user_text=_LAST_USER_TEXT[0],
store=memory,
# Phase 3 先不暴露 web_search:搜索仍走 legacy 路径,
# 等 flows 文字路径验收通过后再接(交接文档要求分阶段)。
search=None,
memory_limit=cfg.context.max_memories,
memory_timeout_s=cfg.context.memory_timeout_s,
)
bridge = FlowsBridge(rtvi=rtvi, tts=tts, cfg=cfg, audio_enabled=audio_enabled)
tool_schemas = agent_tools.available_schemas(ctx_factory())
return llm, aggregator, bridge, ctx_factory, tool_schemas
def make_flow_manager(*, worker, llm, aggregator):
"""worker 建好之后再建 FlowManager。"""
from pipecat.flows import FlowManager
return FlowManager(worker=worker, llm=llm, context_aggregator=aggregator)
async def initialize_conversation_node(
flow, *, display_name: str, tool_schemas: list[dict],
ctx_factory: Callable[[], agent_tools.ToolContext],
) -> None:
"""设置初始节点。
**必须 await。** `FlowManager.initialize` 是协程,但签名显示 `-> None`,
漏 await 不报错,只是人格/任务/工具**全部静默失效**——
Phase 1 spike 在这里栽过一次,表现极具误导性:
模型会正常回复(因为 LLM 照样被调用),但没有人格、没有工具。
"""
node = _conversation_node(display_name=display_name,
tool_schemas=tool_schemas,
ctx_factory=ctx_factory)
await flow.initialize(node)
__all__ = [
"FlowsBridge", "UserTextRecorder", "build_flows_parts", "make_flow_manager",
"initialize_conversation_node",
"set_last_user_text", "PERSONA_RULES",
]