Phase 4:补齐通用记忆闭环(验收 9/9 通过)
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 4 完成。 新增 p1/phase4_acceptance.py:跑真实服务端验 7 条,每条之间查数据库。 实测结果(2026-09-16) 1. "记住我不吃香菜" → confirmed 落库 ✅ 2. **新连接** "我不吃什么?" → "你不吃香菜。" ✅ 连接开始时加载档案生效 3. "我今天吃了香菜" → 不新增 confirmed,模型还追问了是否口味变了 ✅ 4. "其实我现在吃香菜了" → 同键仍只一条 confirmed,值已更新 ✅ 5. "忘掉我的饮食偏好" → 已删除 ✅ 6/7. 访客与跨用户隔离 → 由单测覆盖(客户端不支持切身份) 实现:会话开始时加载稳定档案 - load_stable_memories():取已确认、未过期记忆(身份未确认返回空) - _conversation_node 改用 build_system_prompt,与 legacy 共用同一套 人格与记忆注入格式;记忆进**同一条** system 消息,仍不用 role_message 修掉一个真实缺陷:Flows 路径误用了语音的 max_tokens - 症状:一句"忘掉我的饮食偏好",服务端日志 "Failed to parse function call arguments: {"query": "饮食"(JSON 被截断), 回复为空、记忆没删掉,且**没有任何报错** - 根因:Flows 路径沿用了语音的 max_tokens=200(为"回复要短"而设)。 原始 API 对照实测:max_tokens=200 时模型 finish=stop、根本不调工具; 600 时正常产生 tool_calls 且参数 JSON 完整 - 修法:新增 AgentConfig.max_tokens(AI_AGENT_MAX_TOKENS,默认 600), Flows 路径用它,语音路径仍用 200 新增 client/test_client.py 能力(为验收所需) - RTVIReplies:收服务端 RTVI 文本消息(bot-output / bot-transcription) - 打印 BOT_REPLY 供验收脚本断言 回归:test_agent_tools 45/45、test_flows_runtime 24/24、 test_memory 30/30、test_identity 19/19 服务器已改回 AI_AGENT_RUNTIME=legacy(改默认是 Phase 7 的事)
This commit is contained in:
+38
-10
@@ -71,16 +71,21 @@ def set_last_user_text(text: str) -> None:
|
||||
|
||||
#: 会话控制状态。**不要把长期记忆复制进来**——Flows 的 state 不是数据库,
|
||||
#: 长期数据一律走 MemoryStore(交接文档 §七)。
|
||||
def _conversation_node(*, display_name: str, tool_schemas: list[dict],
|
||||
def _conversation_node(*, identity_decision, memories: list,
|
||||
tool_schemas: list[dict],
|
||||
ctx_factory: Callable[[], agent_tools.ToolContext]) -> dict:
|
||||
"""默认节点:普通陪伴对话 + 可用工具。
|
||||
|
||||
人格与任务都放在 task_messages 里——**不用 role_message**,
|
||||
避免两条 system 消息(见模块文档)。
|
||||
人格与记忆都放进**同一条** system 消息(`task_messages`)——
|
||||
**不用 role_message**,避免两条 system 消息(见模块文档)。
|
||||
|
||||
这里复用 legacy 的 `build_system_prompt`,保证两条路径:
|
||||
- 人设与规则完全一致
|
||||
- 记忆注入格式一致
|
||||
- 身份未确认时的"按访客对待"口径一致
|
||||
"""
|
||||
persona = PERSONA_RULES
|
||||
if display_name:
|
||||
persona += f"\n\n当前用户希望被称为:{display_name}。"
|
||||
from ..prompts import build_system_prompt
|
||||
persona = build_system_prompt(identity_decision, memories)
|
||||
|
||||
from pipecat.flows import FlowsFunctionSchema
|
||||
|
||||
@@ -289,7 +294,8 @@ async def build_flows_parts(
|
||||
"""
|
||||
from .llm_service import create_llm_service
|
||||
|
||||
llm = create_llm_service(cfg.llm)
|
||||
# 用 agent 的 max_tokens,不是语音的 200——带工具需要余量
|
||||
llm = create_llm_service(cfg.llm, max_tokens=cfg.agent.max_tokens)
|
||||
if llm is None:
|
||||
raise RuntimeError(
|
||||
f"Flows 路径不支持后端 {cfg.llm.name!r}(只支持 deepseek)。"
|
||||
@@ -326,8 +332,29 @@ def make_flow_manager(*, worker, llm, aggregator):
|
||||
return FlowManager(worker=worker, llm=llm, context_aggregator=aggregator)
|
||||
|
||||
|
||||
async def load_stable_memories(memory, identity, *, limit: int = 8) -> list:
|
||||
"""加载"稳定档案":已确认、未过期的记忆。
|
||||
|
||||
方案 §四要求"稳定档案(姓名、称呼、语言、明确的稳定偏好)
|
||||
**连接开始时加载**",而不是等模型自己想起来查。
|
||||
|
||||
身份未确认 → 返回空。访客不加载任何私人记忆(验收第 6 条)。
|
||||
"""
|
||||
d = identity.current()
|
||||
if not d.confirmed or memory is None:
|
||||
return []
|
||||
try:
|
||||
rows = await memory.list_memories(d.user_id, include_candidates=False)
|
||||
except Exception as e: # noqa: BLE001 — 加载失败不阻塞会话
|
||||
log.warning("加载稳定记忆失败(继续):%s", e)
|
||||
return []
|
||||
# 最近更新的排前面
|
||||
rows.sort(key=lambda m: getattr(m, "updated_at", 0), reverse=True)
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
async def initialize_conversation_node(
|
||||
flow, *, display_name: str, tool_schemas: list[dict],
|
||||
flow, *, identity_decision, memories: list, tool_schemas: list[dict],
|
||||
ctx_factory: Callable[[], agent_tools.ToolContext],
|
||||
) -> None:
|
||||
"""设置初始节点。
|
||||
@@ -337,7 +364,8 @@ async def initialize_conversation_node(
|
||||
Phase 1 spike 在这里栽过一次,表现极具误导性:
|
||||
模型会正常回复(因为 LLM 照样被调用),但没有人格、没有工具。
|
||||
"""
|
||||
node = _conversation_node(display_name=display_name,
|
||||
node = _conversation_node(identity_decision=identity_decision,
|
||||
memories=memories,
|
||||
tool_schemas=tool_schemas,
|
||||
ctx_factory=ctx_factory)
|
||||
await flow.initialize(node)
|
||||
@@ -345,6 +373,6 @@ async def initialize_conversation_node(
|
||||
|
||||
__all__ = [
|
||||
"FlowsBridge", "UserTextRecorder", "build_flows_parts", "make_flow_manager",
|
||||
"initialize_conversation_node",
|
||||
"initialize_conversation_node", "load_stable_memories",
|
||||
"set_last_user_text", "PERSONA_RULES",
|
||||
]
|
||||
|
||||
@@ -27,7 +27,7 @@ from ..config import LLMConfig
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_llm_service(cfg: LLMConfig):
|
||||
def create_llm_service(cfg: LLMConfig, *, max_tokens: int | None = None):
|
||||
"""按配置造 Pipecat LLMService。
|
||||
|
||||
目前只支持 DeepSeek(OpenAI 兼容)。其它后端返回 None,调用方应当
|
||||
@@ -42,12 +42,14 @@ def create_llm_service(cfg: LLMConfig):
|
||||
# **必须显式覆盖模型名。**
|
||||
# Pipecat 的默认是 `deepseek-v4-flash`,该模型不存在,用默认值会 400。
|
||||
# 见 P0-DeepSeek验证报告.md:真实清单只有 deepseek-flash 与 deepseek-v4-pro。
|
||||
# max_tokens 由调用方指定(Flows 路径用 AgentConfig.max_tokens,
|
||||
# 不是语音的 200)。见 create_llm_service 的 max_tokens 参数。
|
||||
settings = DeepSeekLLMService.Settings(
|
||||
model=cfg.model,
|
||||
# 显式关闭思考。Pipecat 的默认恰好也是 disabled,但写出来更安全:
|
||||
# 将来默认值变化时不会悄悄改变行为,也不至于把思考内容送进 TTS。
|
||||
thinking=DeepSeekLLMService.ThinkingConfig(type="disabled"),
|
||||
max_tokens=cfg.max_tokens,
|
||||
max_tokens=max_tokens or cfg.max_tokens,
|
||||
)
|
||||
return DeepSeekLLMService(
|
||||
api_key=cfg.api_key,
|
||||
|
||||
@@ -224,6 +224,14 @@ class AgentConfig:
|
||||
#: "flows" = Pipecat Flows 驱动文字路径(Phase 3 新路径)
|
||||
runtime: str = field(default_factory=lambda: _env("AI_AGENT_RUNTIME", "legacy"))
|
||||
|
||||
#: Flows 路径的 max_tokens。
|
||||
#: **不能沿用语音的 200**:实测同一句"忘掉我的饮食偏好",
|
||||
#: max_tokens=200 时模型直接 finish=stop、根本不调工具;
|
||||
#: 600 时才正常产生 tool_calls 且参数 JSON 完整。
|
||||
#: 200 是为"语音回复要短"设的,而带工具的路径需要额外余量。
|
||||
max_tokens: int = field(
|
||||
default_factory=lambda: _env_int("AI_AGENT_MAX_TOKENS", 600))
|
||||
|
||||
@property
|
||||
def use_flows(self) -> bool:
|
||||
return self.runtime.strip().lower() == "flows"
|
||||
|
||||
@@ -99,7 +99,9 @@ def test_runtime_switch():
|
||||
|
||||
def test_node_shape():
|
||||
print("\n=== 2. 节点配置形态 ===")
|
||||
identity = ManualIdentityProvider("u_a")
|
||||
identity = ManualIdentityProvider()
|
||||
# 称呼来自 IdentityDecision.display_name(不是构造参数)
|
||||
identity.switch("u_a", display_name="林")
|
||||
store = FakeStore()
|
||||
bridge = FR.FlowsBridge(rtvi=FakeRTVI(), tts=FakeTTS(),
|
||||
cfg=AppConfig.load())
|
||||
@@ -117,7 +119,11 @@ def test_node_shape():
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"]}}},
|
||||
]
|
||||
node = FR._conversation_node(display_name="林", tool_schemas=schemas,
|
||||
from app.memory.base import Memory as M, MemoryKind as MK, ConfirmStatus as CS
|
||||
memories = [M(memory_id=1, owner_user_id="u_a", kind=MK.PREFERENCE,
|
||||
key="饮食偏好", value="不吃香菜", confirm_status=CS.CONFIRMED)]
|
||||
node = FR._conversation_node(identity_decision=identity.current(),
|
||||
memories=memories, tool_schemas=schemas,
|
||||
ctx_factory=ctx_factory)
|
||||
|
||||
check("节点名是 conversation", node["name"] == "conversation")
|
||||
@@ -126,7 +132,10 @@ def test_node_shape():
|
||||
check("用 task_messages 承载人格",
|
||||
len(node["task_messages"]) == 1
|
||||
and node["task_messages"][0]["role"] == "system")
|
||||
check("人格里含称呼", "林" in node["task_messages"][0]["content"])
|
||||
sysmsg = node["task_messages"][0]["content"]
|
||||
check("人格里含称呼", "林" in sysmsg, sysmsg[-40:])
|
||||
check("**不含内部 user_id**(验收第 7 条)", "u_a" not in sysmsg)
|
||||
check("稳定记忆已注入", "香菜" in node["task_messages"][0]["content"])
|
||||
check("respond_immediately=False(不自动开口)",
|
||||
node["respond_immediately"] is False)
|
||||
check("工具已注册", len(node["functions"]) == 1)
|
||||
|
||||
Reference in New Issue
Block a user