Files
ai-companion/app/engine.py
T

497 lines
20 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.
"""会话引擎:身份 → 记忆 → LLM → TTS 的核心回路。
这一层不依赖 Pipecat、不依赖任何传输方式,只依赖四个接口
(LLMProvider / MemoryStore / IdentityProvider / LocalTTS)。
好处有两个:
1. **可以用文件音频端到端测试**,不需要麦克风、音箱、WebRTC。
2. 接 Pipecat 时只需写薄适配层;换传输方式(WebRTC / 本地声卡)不动这里。
一轮的完整流程:
用户音频
└→ STT 转写
└→ 身份裁决(决定用谁的记忆)
└→ 记忆检索(带超时,失败不阻塞)
└→ 拼 system(每轮重建,单条)
└→ LLM 流式
└→ 按句切分 → 逐句 TTS → 边合成边播
└→ 回复结束,异步写记忆(绑定创建时的身份)
延迟的关键在最后两步:**必须按句切分后立刻合成**。
VITS 是非自回归的,整段合成完才出声;只有切成短句才能提前开口。
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import AsyncIterator, Sequence
import numpy as np
from .config import AppConfig
from .identity.base import IdentityDecision, IdentityProvider
from .llm.base import ChunkKind, LLMProvider, ToolCall
from .memory.base import (
ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn,
)
from .prompts import build_system_prompt, trim_history
from .search import (
WEB_SEARCH_PROMPT,
WEB_SEARCH_TOOL,
SearchError,
SearXNGSearchClient,
)
from .voice.stt import StreamingSTT
from .voice.tts import LocalTTS, split_sentences, text_for_speech
log = logging.getLogger(__name__)
_SOURCE_SECTION_RE = re.compile(
r"(?:^|\n)\s*(?:参考来源|资料来源|来源)\s*[::]",
re.IGNORECASE,
)
class EventKind(str, Enum):
IDENTITY = "identity" # 本轮的身份裁决结果
MEMORY_USED = "memory_used" # 检索到的记忆
TRANSCRIPT = "transcript" # STT 结果
RESPONSE_DELTA = "response_delta" # LLM 增量文本(未播,仅供显示)
SPEAK = "speak" # 一段可播放的音频
TOOL_CALL = "tool_call" # 模型请求调用工具
METRICS = "metrics" # 本轮计时
ERROR = "error"
@dataclass
class Event:
kind: EventKind
text: str = ""
audio: np.ndarray | None = None
sample_rate: int = 16000
identity: IdentityDecision | None = None
memories: list[Memory] = field(default_factory=list)
tool_call: ToolCall | None = None
metrics: "TurnMetrics | None" = None
error: str = ""
@dataclass
class TurnMetrics:
"""按方案 §10 要求记录耗时,便于定位瓶颈。"""
stt_ms: float = 0.0
identity_ms: float = 0.0
retrieve_ms: float = 0.0
llm_first_content_ms: float = 0.0
first_audio_ms: float = 0.0 # 从开始处理到第一段音频就绪
total_ms: float = 0.0
llm_backend: str = ""
memories_used: int = 0
reply_chars: int = 0
speaks: int = 0
search_ms: float = 0.0
searches: int = 0
def summary(self) -> str:
return (f"STT {self.stt_ms:.0f}ms | 身份 {self.identity_ms:.0f}ms | "
f"检索 {self.retrieve_ms:.0f}ms | LLM首token {self.llm_first_content_ms:.0f}ms | "
f"首音 {self.first_audio_ms:.0f}ms | 总 {self.total_ms:.0f}ms | "
f"{self.llm_backend} | 记忆{self.memories_used}条 | "
f"搜索{self.searches}次/{self.search_ms:.0f}ms | {self.speaks}段音频")
class ConversationEngine:
def __init__(
self,
cfg: AppConfig,
llm: LLMProvider,
memory: MemoryStore,
identity: IdentityProvider,
tts: LocalTTS,
stt: StreamingSTT | None = None,
search: SearXNGSearchClient | None = None,
):
self.cfg = cfg
self.llm = llm
self.memory = memory
self.identity = identity
self.tts = tts
self.stt = stt
self.search = search or (
SearXNGSearchClient(cfg.search) if cfg.search.enabled else None
)
#: 会话历史。切身份时会清空——方案 §8 要求切换身份
#: 清理上一人的私有检索上下文。
self._history: list[dict] = []
self._history_identity_version: int = -1
self._session_id: str = ""
self._current_turn: asyncio.Task | None = None
# ------------------------------------------------------------ 会话
async def start_session(self, session_id: str | None = None,
user_id: str | None = None) -> str:
sid = session_id or f"s_{uuid.uuid4().hex[:12]}"
if user_id:
self.identity.switch(user_id, reason="会话指定")
d = self.identity.current()
await self.memory.start_session(sid, d.user_id)
self._session_id = sid
self._history_identity_version = self._identity_version()
log.info("会话开始 %s,身份 %s", sid, d.user_id)
return sid
async def end_session(self) -> None:
if self._session_id:
await self.memory.end_session(self._session_id)
def _identity_version(self) -> int:
# ManualIdentityProvider 有 version;其他实现没有时退化为 0
return getattr(self.identity, "version", 0)
def _sync_identity_scope(self) -> None:
"""身份变了就丢弃历史。
不做这件事的后果:切到新用户后,模型仍看得到上一人的对话,
第一条回复就可能带出别人的信息。
"""
v = self._identity_version()
if v != self._history_identity_version:
if self._history:
log.info("身份变更,丢弃 %d 条历史消息", len(self._history))
self._history = []
self._history_identity_version = v
# ------------------------------------------------------------ 主回路
async def respond_to_text(self, user_text: str, *,
session_id: str | None = None) -> AsyncIterator[Event]:
"""处理一句用户输入,产出事件流。"""
t_start = time.perf_counter()
metrics = TurnMetrics()
sid = session_id or self._session_id or await self.start_session()
self._sync_identity_scope()
identity = self.identity.current()
metrics.identity_ms = (time.perf_counter() - t_start) * 1000
yield Event(kind=EventKind.IDENTITY, identity=identity)
if not user_text.strip():
yield Event(kind=EventKind.METRICS, metrics=metrics)
return
# ---- 记忆检索:带超时,失败不阻塞(方案 §8)
t0 = time.perf_counter()
memories = await self._retrieve_memories(identity, user_text)
metrics.retrieve_ms = (time.perf_counter() - t0) * 1000
metrics.memories_used = len(memories)
if memories:
yield Event(kind=EventKind.MEMORY_USED, memories=memories)
# ---- 拼 system(每轮重建,单条)
system = build_system_prompt(
identity,
memories,
extra=WEB_SEARCH_PROMPT if self.search is not None else "",
)
history = trim_history(self._history,
max_turns=self.cfg.context.max_turns)
messages = [{"role": "system", "content": system}] + history + \
[{"role": "user", "content": user_text}]
# ---- LLM 流式 + 逐句 TTS
reply_parts: list[str] = []
buffer = ""
speech_done = False
speaks = 0
async for ev in self._stream_and_speak(messages, metrics):
if ev.kind == EventKind.RESPONSE_DELTA:
reply_parts.append(ev.text)
# 增量文本必须**原样透传**给调用方(显示、字幕、日志都要用)。
# 这里漏掉 yield 的后果是:音频正常出来了,但上层拿到的是空回复——
# 因为文本只进了内部累加器,从未离开这个生成器。
yield ev
if speech_done:
continue
buffer += ev.text
source_start = _SOURCE_SECTION_RE.search(buffer)
if source_start:
buffer = buffer[:source_start.start()]
speech_done = True
# 攒到句末就送去合成——这是提前出声的唯一手段
segs = split_sentences(buffer)
if len(segs) > 1:
*ready, buffer = segs
for seg in ready:
async for speak_ev in self._speak(seg, metrics):
speaks += 1
yield speak_ev
else:
yield ev
# 收尾:把剩下的半句也合成掉
if buffer.strip():
async for speak_ev in self._speak(buffer, metrics):
speaks += 1
yield speak_ev
reply = "".join(reply_parts).strip()
metrics.reply_chars = len(reply)
metrics.speaks = speaks
metrics.total_ms = (time.perf_counter() - t_start) * 1000
# ---- 落历史
self._history.append({"role": "user", "content": user_text})
if reply:
self._history.append({"role": "assistant", "content": reply})
# ---- 异步写记忆:**绑定创建时的身份**,不读"当前用户"
if self.cfg.memory.auto_extract_enabled and reply:
self._spawn_memory_write(
user_text=user_text, reply=reply,
user_id=identity.user_id,
session_id=sid,
turn_id=f"t_{uuid.uuid4().hex[:10]}")
await self.memory.add_turn(Turn(
turn_id=f"t_{uuid.uuid4().hex[:10]}", session_id=sid,
user_id=identity.user_id, role="user", text=user_text))
yield Event(kind=EventKind.METRICS, metrics=metrics)
async def respond_to_audio(self, samples: np.ndarray, *,
session_id: str | None = None) -> AsyncIterator[Event]:
"""从音频走完整条链路(需要 STT)。"""
if self.stt is None:
yield Event(kind=EventKind.ERROR, error="未配置 STT,无法处理音频")
return
t0 = time.perf_counter()
stream = self.stt.new_stream()
stream.accept(samples)
text = stream.finish()
stt_ms = (time.perf_counter() - t0) * 1000
yield Event(kind=EventKind.TRANSCRIPT, text=text)
async for ev in self.respond_to_text(text, session_id=session_id):
if ev.kind == EventKind.METRICS and ev.metrics:
ev.metrics.stt_ms = stt_ms
yield ev
# ------------------------------------------------------------ 内部
async def _retrieve_memories(self, identity: IdentityDecision,
query: str) -> list[Memory]:
"""带超时的检索。超时/出错一律返回空,绝不让对话卡住。"""
if not identity.confirmed:
# 访客不做个性化检索(方案 §6:"未知身份只用访客临时上下文")
return []
try:
return await asyncio.wait_for(
self.memory.retrieve(
identity.user_id, query,
limit=self.cfg.context.max_memories,
scopes=[Scope.PRIVATE, Scope.SHARED]),
timeout=self.cfg.context.memory_timeout_s)
except asyncio.TimeoutError:
log.warning("记忆检索超时 %.2fs,本轮不带记忆继续",
self.cfg.context.memory_timeout_s)
return []
except Exception as e: # noqa: BLE001
log.warning("记忆检索失败(%s),本轮不带记忆继续", e)
return []
async def _stream_and_speak(self, messages: list[dict],
metrics: TurnMetrics) -> AsyncIterator[Event]:
"""执行 LLM/搜索工具闭环;最终文本仍然流式交给调用方做 TTS。"""
t0 = time.perf_counter()
try:
# 最多允许连续两轮搜索;第三次请求不再暴露工具,强制形成回答,
# 防止模型在搜索词之间无限循环。
for round_index in range(3):
tool_defs = (
[WEB_SEARCH_TOOL]
if self.search is not None and round_index < 2
else None
)
tool_acc: dict[int, dict[str, str]] = {}
assistant_parts: list[str] = []
async for chunk in self.llm.stream(messages, tools=tool_defs):
if chunk.kind == ChunkKind.CONTENT and chunk.text:
assistant_parts.append(chunk.text)
if not metrics.llm_first_content_ms:
metrics.llm_first_content_ms = (
time.perf_counter() - t0
) * 1000
yield Event(kind=EventKind.RESPONSE_DELTA, text=chunk.text)
elif chunk.kind == ChunkKind.TOOL_CALL:
slot = tool_acc.setdefault(
chunk.tool_index,
{"id": "", "name": "", "arguments": ""},
)
# base.py 传来的是截至当前分片的完整累计值,直接覆盖,
# 不能再次拼接,否则参数会重复。
slot["id"] = chunk.tool_id or slot["id"]
slot["name"] = chunk.tool_name or slot["name"]
slot["arguments"] = chunk.tool_arguments
metrics.llm_backend = getattr(
self.llm, "last_backend", self.llm.name)
if not tool_acc:
break
calls: list[ToolCall] = []
api_calls: list[dict] = []
for index, raw in sorted(tool_acc.items()):
call = ToolCall(
id=raw["id"] or f"call_{uuid.uuid4().hex[:12]}",
name=raw["name"],
arguments=raw["arguments"] or "{}",
)
calls.append(call)
api_calls.append({
"id": call.id,
"type": "function",
"function": {
"name": call.name,
"arguments": call.arguments,
},
})
yield Event(kind=EventKind.TOOL_CALL, tool_call=call)
messages.append({
"role": "assistant",
"content": "".join(assistant_parts) or None,
"tool_calls": api_calls,
})
for call in calls:
result = await self._execute_tool(call, metrics)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
except asyncio.CancelledError:
log.info("本轮被取消(打断)")
raise
except Exception as e: # noqa: BLE001
log.error("LLM 流失败:%s", e)
yield Event(kind=EventKind.ERROR, error=f"{type(e).__name__}: {e}")
metrics.llm_backend = getattr(self.llm, "last_backend", self.llm.name)
async def _execute_tool(self, call: ToolCall, metrics: TurnMetrics) -> str:
"""执行只读搜索工具;错误作为工具结果回灌,不让整轮对话中断。"""
if call.name != "web_search" or self.search is None:
return "工具不可用。请不要声称已经执行。"
query = call.parsed_arguments().get("query")
if not isinstance(query, str) or not query.strip():
return "搜索失败:缺少有效的 query 参数。"
started = time.perf_counter()
metrics.searches += 1
try:
results = await self.search.search(query)
log.info("联网搜索 %r:返回 %d 条", query[:80], len(results))
return self.search.for_model(query, results)
except SearchError as exc:
log.warning("联网搜索失败:%s", exc)
return f"搜索失败:{exc}。请明确告知用户暂时无法取得实时资料。"
except Exception as exc: # noqa: BLE001 — 工具失败不能拖垮对话
log.exception("联网搜索异常:%s", exc)
return "搜索服务发生异常。请明确告知用户暂时无法取得实时资料。"
finally:
metrics.search_ms += (time.perf_counter() - started) * 1000
async def _speak(self, text: str, metrics: TurnMetrics) -> AsyncIterator[Event]:
"""合成一段并产出可播放音频。"""
text = text_for_speech(text)
if not text:
return
t0 = time.perf_counter()
try:
# TTS 是 CPU 密集的同步调用,放线程里跑,别阻塞事件循环
audio = await asyncio.to_thread(self.tts.synthesize, text)
except Exception as e: # noqa: BLE001
log.error("TTS 失败:%s", e)
yield Event(kind=EventKind.ERROR, error=f"TTS 失败:{e}")
return
if audio is None or len(audio) == 0:
return
if not metrics.first_audio_ms:
metrics.first_audio_ms = (time.perf_counter() - t0) * 1000
yield Event(kind=EventKind.SPEAK, text=text, audio=audio,
sample_rate=self.tts.sample_rate)
def _spawn_memory_write(self, *, user_text: str, reply: str,
user_id: str, session_id: str, turn_id: str) -> None:
"""起一个后台任务写记忆。
**身份是参数传进来的,不在这里读 self.identity.current()。**
方案 §8 明确要求:"异步任务绑定创建时的 user_id/session_id,
避免迟到结果写入新用户。" 如果这里去读"当前身份",
用户在这几百毫秒内切了身份,这条记忆就会写到别人名下。
"""
async def _write():
try:
for mem in extract_memories(user_text, reply):
mem.owner_user_id = user_id # 绑定创建时的身份
mem.source_session_id = session_id
mem.source_turn_id = turn_id
mem.confirm_status = ConfirmStatus(
self.cfg.memory.auto_extract_confirm_status)
await self.memory.remember(mem)
except Exception as e: # noqa: BLE001
log.warning("异步写记忆失败(不影响对话):%s", e)
task = asyncio.create_task(_write())
# 持有引用,避免被 GC 提前回收
if not hasattr(self, "_bg_tasks"):
self._bg_tasks: set[asyncio.Task] = set()
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)
# ---------------------------------------------------------------- 记忆抽取
#: 显式"记住"的触发词。首版用规则,不额外调一次 LLM——
#: 那会多一次往返,而语音场景对延迟很敏感。
_REMEMBER_PATTERNS = (
("记住", ""), ("记一下", ""), ("别忘了", ""), ("帮我记", ""),
("我喜欢", "偏好"), ("我不喜欢", "偏好"), ("我习惯", "偏好"),
)
def extract_memories(user_text: str, reply: str) -> list[Memory]:
"""从一轮对话里抽取候选记忆。
首版只认显式表达。**抽取结果默认是 candidate,不参与检索**——
方案 §8:"模型推测不当作确认事实"。要变成 confirmed 需要用户确认
或走工具调用。
"""
out: list[Memory] = []
text = (user_text or "").strip()
for trigger, kind_hint in _REMEMBER_PATTERNS:
if trigger in text:
# 取触发词之后的正文
body = text.split(trigger, 1)[1].strip(" ::,,。.!!??")
if len(body) < 2:
continue
out.append(Memory(
kind=MemoryKind.PREFERENCE if kind_hint else MemoryKind.FACT,
key=kind_hint or "",
value=body[:200],
))
break
return out
__all__ = [
"ConversationEngine", "Event", "EventKind", "TurnMetrics", "extract_memories",
]