Files
ai-companion/app/prompts.py
T
ylhan 35e107c290 助手本体:会话引擎、LLM 路由、记忆、身份、语音适配
从 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 恢复
2026-09-15 11:05:09 +08:00

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"]