Files
ylhan 4fe2755fd6 Phase 2:抽出工具与策略层
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 2 完成。
legacy 路径行为不变,只是改为复用新工具层。

新增 app/agent/
- memory_policy.py:记忆写入/删除的裁决层。模型只提议,策略决定。
  不依赖 LLM/网络/真实存储,可纯单元测试。
  - 身份未确认 → 一律 reject,不碰私人记忆
  - 模型传 owner_user_id/scope 等越权字段 → reject(不静默忽略)
  - 只有用户明确说"记住/以后/别忘了/我的偏好是"才写 confirmed
  - "记住我今天吃了香菜"这类一次性事件 → 降级 candidate
  - 不采信模型自称的"用户明确要求"
  - 模糊/多条删除 → needs_confirmation,不猜目标
- tools.py:工具 schema 与执行器,legacy 与 flows 共用。
  - 身份由 ToolContext 注入,**不在工具参数里**
  - 身份未确认时不暴露任何记忆工具(比暴露后拒绝更干净)
  - 写入结果如实回报:candidate 明确告知"尚未生效",
    删除如实返回"已删除/没有找到/需要确认",不把"请求已收到"说成"已做到"
  - 工具失败转成给模型的文本,不拖垮本轮对话
- llm_service.py:Pipecat LLMService 工厂,固化 Phase 1 spike 的选型结论
  (复用 DeepSeekLLMService;必须显式覆盖模型名为 deepseek-flash,
  因为 Pipecat 默认的 deepseek-v4-flash 不存在)

改造 app/engine.py
- _execute_tool 改为委托 app/agent/tools.execute(),删除重复实现
- 本轮用户原话挂到实例上,供策略层判定语义等级
- 移除不再使用的 SearchError 导入

新增 app/tests/test_agent_tools.py:45 项断言,**不依赖 LLM/网络/SQLite**
(用 fake store)。覆盖交接文档 §十 的清单:越权参数、未确认身份、
明确记住 vs 推断、同 key 幂等、模糊删除、精确删除、跨用户隔离。

回归:test_agent_tools 45/45、test_memory 30/30、test_identity 19/19、
test_search 12/12、test_transport 4/4、test_profile_memory 22/22,无退化。
2026-09-16 13:25:46 +08:00

607 lines
25 KiB
Python
Raw Permalink 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 .agent.tools import ToolContext, execute as execute_tool
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,
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,
)
_DISPLAY_NAME_PATTERNS = (
re.compile(
r"(?:我叫|我的(?:名字|姓名)(?:是|叫)|(?:你可以)?叫我)\s*"
r"[“\"']?([^,,。!?!?;;::\n\"”']{1,24})[”\"']?"
r"(?=$|[,,。!?!?;;::\n])",
re.IGNORECASE,
),
# 兼容语音识别常见的“我是林”。只接受 1–4 个中文姓名字符,
# 避免把“我是程序员/中国人”一类身份描述当作姓名。
re.compile(
r"我是\s*([\u4e00-\u9fff·]{1,4})"
r"(?=$|[,,。!?!?;;::\n])"
),
)
_NOT_NAMES = {
"程序员", "工程师", "中国人", "学生", "老师", "医生", "男生", "女生",
"用户", "访客", "谁", "什么", "哪位", "你", "他", "她", "它", "你的", "他的", "她的",
}
_NAME_QUESTION_WORDS = ("谁", "什么", "哪位")
_NAME_QUESTION_PARTICLES = ("吗", "么", "呢")
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)
if d.confirmed:
try:
display_name = await self.memory.get_user_display_name(d.user_id)
if display_name:
self.identity.switch(
d.user_id,
display_name=display_name,
reason="从用户档案恢复称呼",
)
d = self.identity.current()
except Exception as exc: # noqa: BLE001 — 档案读取失败不阻塞会话
log.warning("读取用户称呼失败(本轮继续):%s", exc)
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()
display_name = extract_display_name(user_text)
if display_name and identity.confirmed:
await self._remember_display_name(identity, display_name, sid)
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
# 策略层要拿用户原话来判定"明确要求记住"还是"模型推断",
# 所以本轮原话要挂到实例上,供 _execute_tool 构造 ToolContext。
self._current_user_text = user_text
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 _remember_display_name(
self,
identity: IdentityDecision,
display_name: str,
session_id: str,
) -> None:
"""同步保存用户明确自报的称呼,保证断开连接前已经落盘。"""
turn_id = f"t_{uuid.uuid4().hex[:10]}"
async def write_profile() -> None:
await self.memory.set_user_display_name(identity.user_id, display_name)
await self.memory.remember(Memory(
owner_user_id=identity.user_id,
kind=MemoryKind.FACT,
key="姓名",
value=display_name,
confirm_status=ConfirmStatus.CONFIRMED,
source_session_id=session_id,
source_turn_id=turn_id,
))
try:
await asyncio.wait_for(
write_profile(),
timeout=max(1.0, self.cfg.context.memory_timeout_s),
)
log.info("已确认并保存用户称呼:%s", display_name)
except Exception as exc: # noqa: BLE001 — 记忆失败仍允许本轮继续
log.warning("保存用户称呼失败(仅本轮生效):%s", exc)
# 自报称呼只用于个性化,保持原 user_id 和 trust_level,不构成认证。
self.identity.switch(
identity.user_id,
display_name=display_name,
reason="用户明确自报称呼",
)
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:
"""执行工具。**实现已抽到 `app/agent/tools.py`,两条路径共用。**
legacy 与 Flows 必须用同一套 schema 和执行器,否则容易两处行为漂移
(交接文档 Phase 2 的完成标准就是"工具层可被两条路径复用")。
这里只负责把引擎的状态转成 ToolContext。
"""
identity = self.identity.current()
ctx = ToolContext(
owner_user_id=identity.user_id, # ← 服务端注入,模型不得传
session_id=self._session_id,
turn_id=f"t_{uuid.uuid4().hex[:10]}",
identity_confirmed=identity.confirmed,
user_text=getattr(self, "_current_user_text", ""),
store=self.memory,
search=self.search,
memory_limit=self.cfg.context.max_memories,
memory_timeout_s=self.cfg.context.memory_timeout_s,
)
started = time.perf_counter()
outcome = await execute_tool(call.name, call.parsed_arguments(), ctx)
if call.name == "web_search":
metrics.searches += 1
metrics.search_ms += (time.perf_counter() - started) * 1000
return outcome.content
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()
# 姓名由同步、已确认的档案路径处理;不要再产生一条 candidate 副本。
if extract_display_name(text):
return out
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
def extract_display_name(user_text: str) -> str | None:
"""从用户明确的自我介绍中提取称呼;不使用模型猜测。"""
text = " ".join((user_text or "").strip().split())
if not text:
return None
for pattern in _DISPLAY_NAME_PATTERNS:
match = pattern.search(text)
if not match:
continue
name = " ".join(match.group(1).strip(" ‘'\"”").split())
if not name or len(name) > 24 or name in _NOT_NAMES:
continue
# “知道我是谁吗”“我是林吗”不是自我介绍。语音识别可能不带问号,
# 所以不能只依赖标点,需同时过滤疑问词和句末疑问助词。
if any(word in name for word in _NAME_QUESTION_WORDS):
continue
if name.endswith(_NAME_QUESTION_PARTICLES):
continue
if name.startswith(("你", "他", "她", "它")) and len(name) > 1:
continue
return name
return None
__all__ = [
"ConversationEngine", "Event", "EventKind", "TurnMetrics",
"extract_memories", "extract_display_name",
]