从 Obsidian 笔记库剥离为独立仓库。规划文档与 P0 报告留在笔记库, 代码(助手本体、TTS 服务、P0 基准脚本、ESP32 固件)在此。 架构:服务器是助手,本地是客户端 - 助手本体跑在 cadadmin-server,N5095 只做 USB 外设桥与网络桥接 - 起因:实测 N5095 无 AVX2,跑不动可用的语音模型 当前技术栈 - LLM:DeepSeek deepseek-flash(云端,必须关思考) - TTS:Qwen3-TTS 1.7B(服务器 3090,独立 HTTP 服务) - STT:Zipformer 中英双语 int8(CPU,中文识别率未测) 实测:端到端 16/16 通过,首音延迟 1545ms (对比:本地 LLM + vits-zh-ll 是 179ms,但音色不满意——音质与延迟 在此硬件上此消彼长) 设计要点 - MemoryStore 接口按"可能远程"设计(全 async、可超时、可失败) - user_id / session_id / track_id 三者分开,人脸只作候选不自动切身份 - 记忆查询强制带 owner_user_id,无按 id 单独取的口子 - 自动抽取的记忆是"候选",默认不参与检索 - 异步写记忆绑定创建时的身份,不读"当前用户" - 引擎与传输解耦,只用文件音频即可端到端测试 模型与音频产物不进版本管理(1.1GB),由 download_models.sh 恢复
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
"""提示词构造。
|
|
|
|
关键约束(实测,P0-本地LLM验证报告.md §四):
|
|
|
|
本机 vLLM **只接受一条 system 消息**,多条返回
|
|
400 "System message must be at the beginning."
|
|
|
|
所以记忆不能作为"第二条 system"注入,必须**并进同一条里、每轮重建**。
|
|
|
|
这其实比追加式更干净,也正好满足方案 §8 的要求:
|
|
"切换身份清理上一人的私有检索上下文"、"注入的记忆每轮替换,不是追加"——
|
|
每轮从零拼出来的 system 天然不会有上一人的残留。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Sequence
|
|
|
|
from .identity.base import IdentityDecision
|
|
from .memory.base import Memory
|
|
|
|
#: 语音助手的回复风格。方案 §5:"默认简短自然回复"。
|
|
#: 这几条不是"风格偏好",是**功能性的**——放出去的文本会直接进 TTS,
|
|
#: 带 Markdown 的话会被逐字念出来("井号井号 第一步")。
|
|
PERSONA_RULES = """\
|
|
你是一个桌面陪伴助手,通过语音和用户交流。用户听到的是合成语音,不是文字。
|
|
|
|
必须遵守:
|
|
1. 回复简短自然,适合朗读,默认一到两句话。不要长篇解释。
|
|
2. 绝对不要使用 Markdown、星号、井号、列表、表格、表情符号或括号注释。
|
|
3. 数字、日期、金额按中文口语写法,例如"一千二百元""下午三点半"。
|
|
4. 不确定的事直说不确定,不要编造。没做过的事不要说已经做了。
|
|
5. 需要操作设备时调用工具。不要假装已经执行,也不要说"正在为您打开"。
|
|
6. 用户打断时立刻停止,不要坚持把上一句说完。"""
|
|
|
|
|
|
def build_system_prompt(
|
|
identity: IdentityDecision,
|
|
memories: Sequence[Memory] = (),
|
|
*,
|
|
extra: str = "",
|
|
) -> str:
|
|
"""拼出本轮唯一的 system 消息。
|
|
|
|
每轮调用一次,不要缓存后复用——身份或记忆一变,缓存就会串。
|
|
"""
|
|
parts = [PERSONA_RULES]
|
|
|
|
if identity.confirmed:
|
|
parts.append(f"\n当前用户:{identity.user_id}(已确认身份)。")
|
|
else:
|
|
# 方案 §6:未知身份只用访客上下文,不做个性化
|
|
parts.append("\n当前用户身份未确认,按访客对待。"
|
|
"不要提及任何私人信息,也不要假定你知道对方是谁。")
|
|
|
|
if memories:
|
|
lines = ["\n已知的用户信息(供参考,不要逐条复述):"]
|
|
for m in memories:
|
|
lines.append(f"- {m.text}")
|
|
parts.append("\n".join(lines))
|
|
|
|
if identity.suggestions and not identity.confirmed:
|
|
# 有人脸候选但不自动切换。让助手可以自然地问一句,
|
|
# 而不是自作主张切换档案(方案 §6:"看到熟人不等于确认他正在说话")。
|
|
names = "、".join(
|
|
s.candidate_user_id for s in identity.suggestions[:2])
|
|
parts.append(
|
|
f"\n注意:摄像头可能认出了 {names},但这不代表对方正在说话。"
|
|
f"不要据此切换身份或提及该用户的私人信息。")
|
|
|
|
if extra:
|
|
parts.append("\n" + extra.strip())
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
def trim_history(
|
|
history: Sequence[dict],
|
|
*,
|
|
max_turns: int,
|
|
max_chars: int = 4000,
|
|
) -> list[dict]:
|
|
"""裁掉过旧的对话轮次。
|
|
|
|
方案 §8 要求"对话摘要限长、按需检索",不默认永久保存逐字稿。
|
|
这里做最简单的一档:保留最近 max_turns 轮,再按字符数兜底。
|
|
上下文越短,本地 LLM 的首 token 越短——这不是省资源,是直接买延迟。
|
|
"""
|
|
msgs = list(history)
|
|
# 一轮 = 一问一答,所以保留的消息数是轮数的两倍
|
|
keep = max_turns * 2
|
|
if len(msgs) > keep:
|
|
msgs = msgs[-keep:]
|
|
|
|
# 按字符数从旧到新裁,保证裁完仍然从 user 消息开始
|
|
while msgs and sum(len(m.get("content") or "") for m in msgs) > max_chars:
|
|
msgs.pop(0)
|
|
while msgs and msgs[0].get("role") != "user":
|
|
msgs.pop(0)
|
|
return msgs
|
|
|
|
|
|
__all__ = ["build_system_prompt", "trim_history", "PERSONA_RULES"]
|