16654 字
83 分钟
MAYA2.0:全离线物联网 Agent

MAYA2.0 是一套装在家里的全离线语音中枢。

它想做的事和 1.0 是一脉相承的——让家里那些琐碎的事(开灯、关空调、屋里闷不闷)用说话就能办完。但这次的实现路子完全不同:不再靠一块 STM32 去驱动继电器,而是把语音识别、大模型、语音合成整条链路都塞进一台本地设备里,一句话都不出家门。

⚠️ 先说清楚一件容易搞混的事:MAYA2.0 和 MAYA1.0 是两个不同的项目,不是同一条产品线的两次迭代。1.0 的重心在硬件——STM32 主控、ASR-PRO 语音模块、OpenMV 视觉、四台 ESP8266 自组网,交付物是一台桌面机器人;2.0 的重心在软件——跑在 RDK X5 上的一套 Python 系统,交付物是一个语音 Agent。两者共用「MAYA」这个名字和「本地、可控、可解释」的设计口味,仅此而已。

项目小档案 开发自 2025 年 11 月 28 日定稿设计文档起,12 月 30 日完成。 这一个月的主角不是业务代码,而是用 Prompt 把一整套离线轻量化模型调度起来——让 ASR、1.5B 的 LLM、TTS 和意图路由像一个整体那样协同;同时把第一 / 第三人称身份写死(谁是「我」、把对方称为什么), 让 AI 更聪明地落地,也更贴合真实的应用环境。1.5B 的能力上限第二天就摸到了,之后所有的效果提升, 全靠「怎么跟它说话」和「它说完之后怎么收拾」一点点挣出来。

下面分两半讲:前半是架构(它凭什么能在 10 TOPS 上跑起来),后半是踩坑(1.5B 的小模型到底有多不听话)。

一、它由哪些部分组成#

选型角色
中枢计算RDK X5(10 TOPS)同时跑 ASR + LLM + TTS + 对话调度
语音识别Whisper(small)离线转写,中文识别精度够用
大模型Qwen2.5-1.5B-Instruct(q4_k_m GGUF)llama.cpp 后端,全本地推理
语音合成Piper(huayan 花颜)离线、低延迟
设备通信MQTT(Mosquitto)一对多控制 ESP32
终端设备ESP32 × N继电器 / 舵机 / 传感器
交互界面PyQt6 终端对话、状态面板、设置
数据存储SQLite × 3记忆 / 隐私 / 时空状态

「全离线」这三个字是有代价的,也是这个项目所有设计取舍的源头:

  • 没有兜底。云端助手答不上来可以换个更大的模型接着答,这里只有 1.5B 这一张牌,答砸了就是砸了。
  • 算力是真的紧。10 TOPS 要同时养着语音识别、大模型推理和语音合成,还得留出余量给设备调度。
  • 换来的是:断网可用、语音数据不出设备、没有按次计费的 API、行为完全可预期——包括可以随时打开数据库,把系统「记住」的东西一条条翻出来看、删掉。

二、10 TOPS 的账:为什么是 1.5B#

选模型就是算账。一台本地设备要同时装下语音识别、大模型、语音合成三套模型,模型越大越聪明,但留给语音链路和调度逻辑的余量就越少

量级能力在这台设备上
7B明显更强,能处理复杂推理延迟与显存都吃不下,语音交互的「即时感」会先崩
3B折中余量紧张,语音链路会被挤压
1.5B够做短对话、够守规则性能与能力的平衡点

真正拍板的不是「哪个最聪明」,而是「哪个能在这个算力下稳定地、口语化地、短促地回话」。语音交互对延迟极其敏感——用户说完到听到回应超过两秒,体验就散了。所以输出长度被死死按在 80 tokens 以内,回答要求控制在 1–3 句话,这既是为了体验,也是为了给单次推理省时间。

量化方面选了 q4_k_m:在 1.5B 这个量级上,4 位量化的精度损失基本看不出来,但显存占用和推理速度的收益是实打实的。

关键参数都在 config/system.yaml 里,值得看一眼:

config/system.yaml(节选)
llm:
model_path: "./models/qwen/qwen2.5-1.5b-instruct-q4_k_m.gguf"
backend: "llama.cpp"
context_size: 2048 # 上下文窗口
n_threads: 4 # GPU 模式下的 CPU 调度线程
n_gpu_layers: -1 # -1 = 全部层放 GPU
n_batch: 512
temperature: 0.6
top_p: 0.9
max_tokens: 80 # 刻意压低:限制长度,避免过长回复和重复
asr:
model_size: "small" # tiny / small / base;small 的中文识别更稳
language: "zh"
audio_sample_rate: 16000
tts:
voice: "huayan"
sample_rate: 22050

这里的 context_size: 2048max_tokens: 80 是一对需要一起看约束:

nctx2048    Tprompt身份+记忆+上下文+实时状态+Toutput80\underbrace{n_{ctx}}_{\text{2048}} \;\ge\; \underbrace{T_{prompt}}_{\text{身份+记忆+上下文+实时状态}} + \underbrace{T_{output}}_{\text{80}}

而 Prompt 里要塞进的区块有 7~8 段(身份、人格、记忆、隐私、上下文、实时状态、用户输入,ROOT 模式下再多一段权限声明),一旦记忆条数放开,T_prompt 会迅速吃掉窗口——这就是后面「记忆最多注入 5 条」这条硬限制的由来,不是拍脑袋定的。

MAYA 2.0全离线语音中枢输入层麦克风阵列——离线唤醒,不联网文字输入——PyQt6 终端 / 命令行认知层RDK X5 · 10 TOPSASR · Whisper small(CPU)意图路由 · IntentRouter(10 种意图)LLM · Qwen2.5-1.5B-Instruct + llama.cppTTS · Piper(huayan 花颜)数据层SQLite × 3记忆库 maya_memory隐私库 maya_privacy时空状态库 maya_realtime执行层MQTT · MosquittoESP32 × N——继电器 / 舵机 / 传感器DeviceSimulator——MQTT 断了就降级

三、从一句话到设备动作#

整条链路是一条流水线。主循环本身很薄,它只做三件事:采集 → 判断有没有叫我 → 交给对话管理器

core/main_loop.py(节选)
def run(self):
while self.running:
# 1. 音频采集与 ASR
asr_result = self.asr_service.listen_and_transcribe()
if not asr_result or not asr_result.get("text"):
continue
user_text = asr_result.get("text", "")
# 2. 检查唤醒词——没叫我就不处理
if not self._check_wake_word(user_text):
continue
# 移除唤醒词,只把有效内容交给下游
user_text_clean = self._remove_wake_word(user_text)
if not user_text_clean.strip():
self.tts_service.speak("我在,请说")
continue
asr_result["text"] = user_text_clean
# 3. 对话处理
result = self.dialogue_manager.process_input(asr_result)
# 4. TTS 输出
if result.get("need_tts") and result.get("response"):
self.tts_service.speak(result["response"])

唤醒词的判定刻意做得宽容——ASR 会把「MAYA」听成各种样子,所以候选表里塞了同音变体:

core/main_loop.py(节选)
def _check_wake_word(self, text: str) -> bool:
wake_variants = [self.wake_word.lower(), "maya", "玛雅", "麦娅", "may a"]
return any(variant in text.lower() for variant in wake_variants)

下游是 DialogueManager.process_input(),它才是真正的调度中枢:先看是不是在等口令,再看要不要 ROOT 模式,然后才把文本交给意图路由。

一次唤醒到设备动作① 拾音麦克风阵列采集16 kHz 单声道② 识别Whisper 离线转写③ 分流IntentRouter 规则优先④a 设备控制抽取房间 / 设备 / 动作 / 数值MQTT → ESP32 → 继电器动作④b 普通对话注入记忆——最多 5 条注入时空状态——时间 / 地点 / 天气仅 ROOT 模式注入隐私LLM 生成回复⑤ 播报Piper 合成后本地播放音频流文本 + 语言意图slotsPrompt语音

四、意图路由:能不用 LLM 就不用 LLM#

这是整个系统里我最满意的设计。

大模型能理解语言,但它的输出不可预期——同样的输入,它可能给你一句话、也可能给你三段话,还可能顺手编个设备名出来。而「打开客厅灯」这种事,压根不需要理解能力,它需要的是确定性

所以系统在 LLM 前面横了一道 IntentRouter:规则能判的,绝不交给模型。

优先级意图判定依据置信度
1WAKE_UPMAYA / 玛雅 / 麦娅 / may a0.95
2DEVICE_CONTROL动作词 + 设备名0.88
3SYSTEM_CONTROL音量 / 模式 / 暂停(调的是 MAYA 自己)0.80
4DEVICE_QUERY吗 / 状态 / 现在 / 开着 + 设备名0.85
5MEMORY_WRITE第一人称 + 习惯词,且不含控制动作0.70–0.80
6MEMORY_QUERY第一人称 + 怎么样 / 是不是 / 多久0.80
7GENERAL_CHAT兜底分类0.70
8FALLBACK_TO_LLM规则全判不出来须 > 0.6

路由主干就是一条 if-else 链,顺序即优先级:

nlu/intent_router.py(节选)
def route_intent(self, asr_text) -> IntentObject:
text = asr_text.get("text", "").strip() if isinstance(asr_text, dict) else str(asr_text).strip()
if not text:
return IntentObject(self.FALLBACK_TO_LLM, 0.0, {}, text)
# 1. 唤醒词
if self._check_wake_up(text):
return IntentObject(self.WAKE_UP, 0.95, {}, text)
# 2. 设备控制
device_control = self._check_device_control(text)
if device_control:
return device_control
# 3. 系统控制(先排除「进入 ROOT 模式」这类请求,避免误分类)
root_keywords = ["进入root", "开启root", "激活root", "root模式", "管理员模式"]
is_root_request = any(kw in text.lower() for kw in root_keywords)
if not is_root_request:
system_control = self._check_system_control(text)
if system_control:
return system_control
# 4~7:设备查询 → 记忆写入 → 记忆查询 → 普通对话
...

设备控制的关键不是「听懂」,而是把槽位抽干净——房间、设备、动作、数值,缺一个下游就没法执行:

nlu/intent_router.py(节选)
def _extract_device_slots(self, text: str) -> Dict[str, Any]:
slots = {"room": None, "device": None, "action": None, "value": None}
for room in self.ROOMS: # 客厅 / 卧室 / 厨房 / 卫生间 / 阳台 / 书房
if room in text:
slots["room"] = room
break
for device in self.DEVICE_NAMES: # 灯 / 风扇 / 空调 / 窗帘 / 插座 / 电视 / 音响
if device in text:
slots["device"] = device
break
if "打开" in text or "开启" in text:
slots["action"] = "ON"
elif "关闭" in text:
slots["action"] = "OFF"
elif "调高" in text:
slots["action"] = "UP"
elif "调低" in text:
slots["action"] = "DOWN"
m = re.search(r'(\d+)', text) # 「调到 26 度」
if m:
slots["value"] = int(m.group(1))
return slots

踩坑:MEMORY_WRITE 太容易误判#

「记忆写入」是这套系统里判定最难的意图。它的出发点很好——用户陈述一个长期习惯,系统记下来,下次就能用上。但第一版规则是「第一人称 + 状态关键词」,于是这些东西全被当成习惯存进了数据库:

  • 「我饿了」「我累了」「我困了」——临时状态,不是习惯
  • 「我感冒了」「我头疼」——一次性的,明天就好了
  • 「我是谁」「我的手机号」——这明明是隐私查询,却因为带了「我」被判成「要记点什么」

修法是加一张排除词表,把临时状态、隐私查询、ROOT 请求、设备控制、问句统统挡在门外:

nlu/intent_router.py(节选)
exclude_keywords = [
"你好", "hello", "谢谢", "再见",
"我是谁", "我的名字", "我的手机", "我的邮箱", # 其实是隐私查询
"进入root", "root模式", "密码", # ROOT 模式相关
"今天天气", "几点了", # 信息查询
"多少", "什么", "为什么", "怎么", "如何", # 问句
"打开", "关闭", "调高", "调低", # 设备控制
"我饿了", "我累了", "我困了", # 临时状态(不是习惯)
"我感冒了", "我生病了", "我头疼", # 一次性状态
]
if any(kw in text_lower for kw in exclude_keywords):
return None

剩下的还得同时满足「有状态词或明确记忆指示词」+「不含控制动作」才认。这条经验对后面很重要:在小模型系统里,规则的误判代价比漏判高——漏判只是没记住,误判会把噪声永久写进用户的长期画像里。

IntentRouter优先级从高到低1 唤醒词MAYA / 玛雅 / 麦娅 / may a2 设备控制动作词 + 设备名 → ON / OFF / UP / DOWN置信度 0.883 系统控制音量 / 模式 / 暂停——调的是 MAYA 本身4 设备查询吗 / 状态 / 现在 / 开着5 记忆写入第一人称 + 习惯词,且不含控制动作曾被“我感冒了”这类临时状态误触发6 记忆查询“我最近睡得怎么样”7 普通对话没有设备对象、没有强结构8 LLM 兜底规则全都判不出来才走模型置信度须高于 0.6 才采信

五、长期记忆:让系统长大,但不许幻觉#

记忆模块有一条贯穿始终的红线:记忆由系统判断,不由 LLM 自发生成

模型没有权限创建、修改或删除记忆。它能做的只有一件事——读被注入到 Prompt 里的那几条。这是刻意的权限收缩,因为它管不住自己:只要给它「你可以记住东西」的自由,它就会开始假装记得上次的对话。

记忆分四类:

类型存什么例子
USER_HABIT作息、饮食、环境偏好「用户通常在 23:30 左右睡觉」
DEVICE_INFO房间结构、设备列表「客厅有灯和风扇」
USER_STATE一段时间内反复出现的状态「用户近期存在晚睡情况」
SYSTEM_EVENT模式切换、长期使用统计——

允许写入的只有三种情况:用户主动、明确陈述长期事实;行为被多次验证(连续多天 23:30 后睡觉);系统长期统计的结论。禁止写入的则是:单次情绪表达、一次性事件、推测性判断、LLM 生成的结论、未经确认的习惯。

写进库之前还会做一次文本标准化,要求是陈述句、不含模糊词、可长期成立、主体明确:

❌ 不合格✅ 标准化后
用户可能不喜欢空调用户不喜欢空调直吹
最近好像睡得不好用户近期存在晚睡情况

表结构很简单,但字段的每一项都有用意——importance 决定检索时谁优先,embedding 是留给语义检索的,last_access 用来做时间衰减:

data/maya_memory.db
CREATE TABLE memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_type TEXT NOT NULL, -- USER_HABIT / DEVICE_INFO / USER_STATE / SYSTEM_EVENT
content TEXT NOT NULL, -- 标准化后的记忆文本
importance INTEGER DEFAULT 1, -- 1~5,越大越重要
embedding BLOB, -- 向量(可选,用于语义检索)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_access DATETIME
);

检索有一条不同于常见 RAG 的原则:规则先于向量,向量只做排序。也就是先用 memory_typeimportance 筛出候选,再用语义相似度对 Top-K 精排——而不是让向量相似度决定「召回什么」。顺序反了,就会出现「问天气却翻出一条关于空调的记忆」这种莫名其妙的结果。

规则优先的检索(节选)
SELECT content FROM memory
WHERE memory_type IN ('USER_HABIT', 'USER_STATE')
ORDER BY importance DESC, last_access DESC
LIMIT 5;

注意那个 LIMIT 5——和前面算的上下文预算是同一件事。记忆注入得越多,T_prompt 越大,留给模型回话的空间越小。注入过量还有一个更隐蔽的副作用:系统会开始「自以为很懂你」,张口就是你如何如何,反而比不认识你还烦人。所以要判断该不该加记忆,可以看两个症状:

  • 表现得「自以为很懂你」→ 记忆注入过多
  • 表现得「毫无个性」→ importance 阈值设得太低
长期记忆生命周期① 允许写入第一人称 + 长期习惯词多次验证过的行为② 拒绝写入单次情绪 / 一次性事件推测性判断与未经确认的习惯③ 文本标准化陈述句、不含模糊词“用户不喜欢空调直吹”④ 落库 SQLitememory_type / importance / embedding⑤ 检索规则优先,向量只用来排序⑥ 注入 Prompt最多 5 条,标注为“历史记录”⑦ 更新与遗忘新习惯覆盖旧习惯长时间未使用则降权硬边界LLM 无权创建 / 修改 / 删除记忆

六、隐私分级与 ROOT 模式#

这是 MAYA2.0 和「一个聊天机器人」最大的区别:它真的存了你的隐私信息,所以必须有一套权限体系。

数据被分成三套独立的库,互不写入,访问权限各不相同:

数据源访问权限存什么更新方式
记忆库 Memory普通 + ROOT长期习惯、偏好对话触发写入
隐私库 Privacy仅 ROOT10 类敏感信息隐私管理 GUI 维护
时空状态库 Realtime普通 + ROOT时间 / 位置 / 天气 / 设备状态定时自动更新
IoT 设备状态普通 + ROOT设备实时状态控制 / 查询时更新

流程是这样的:用户问「我的手机号是多少」,系统先在 process_input() 的最前面做隐私检测——这一步刻意放在意图路由之前,因为隐私查询的措辞太多变,交给规则分类容易被判成普通对话:

core/dialogue_manager.py(节选)
# 【优先级1】检查是否需要 ROOT 模式(必须在意图路由之前)
needs_privacy = self.root_mode_manager.check_needs_privacy(user_text)
requests_root = self.root_mode_manager.check_request_root_mode(user_text)
# 需要隐私但 ROOT 未激活 → 先去要口令
if (needs_privacy or requests_root) and not self.root_mode_manager.is_active():
return self._handle_root_mode_request(user_text, needs_privacy, requests_root)
# ROOT 已激活且需要隐私 → 强制走普通对话分支(那里会读隐私并注入 Prompt)
if self.root_mode_manager.is_active() and needs_privacy:
intent = IntentObject(intent_type=self.intent_router.GENERAL_CHAT, ...)
return self._handle_general_chat(intent, user_text)

口令校验(实现细节:值是配置在代码里的常量,这里不写出来):

core/root_mode_manager.py(结构示意)
class RootModeManager:
# ROOT 模式口令(真实值在源码常量里,这里以占位符示意)
ROOT_PASSWORD = "••••"
def verify_password(self, password: str) -> bool:
"""支持多种格式:纯数字、中文数字、以及 ASR 听起来像的变体"""
password_clean = password.strip().replace(" ", "").replace(",", "").replace(",", "")
if password_clean == self.ROOT_PASSWORD:
return True
# 「二零三九」这类中文念法要能转回数字——语音输入经常吐这种
chinese_to_digit = {"零": "0", "一": "1", "二": "2", "三": "3", ...}
if all(c in chinese_to_digit for c in password_clean):
converted = "".join(chinese_to_digit.get(c, c) for c in password_clean)
if converted == self.ROOT_PASSWORD:
return True
return False

这里有个只做语音系统才会想到的细节:口令校验必须同时接受阿拉伯数字和中文数字(以及「两千零三十九」这类念法),因为用户是用嘴说的,而 ASR 的输出并不稳定。纯文本系统不会有这个问题。

隐私查询还做了分类读取,不是一股脑全倒出来——问名字只读 PERSONAL_INFO,问联系方式只读 CONTACT_INFO,问账号只读 ACCOUNT_INFO。读取不到时才退化成「按重要性取前 10 条」。

三套数据三种权限记忆库 Memory普通 + ROOT 都可读用户习惯 / 设备信息 / 用户状态隐私库 Privacy仅 ROOT 模式 + 口令10 类隐私信息时空状态库 Realtime普通 + ROOT 都可读时间 / 位置 / 天气 / 设备状态注入边界记忆最多注入 5 条隐私仅在 ROOT 激活时出现三库结构上互不写入

七、五模式对照:1.5B 的真实水平#

前面讲的都是「设计成了什么样」。但设计意图和实际行为之间,隔着一条巨大的鸿沟。

为了把这条鸿沟测出来,我做了一件回头看最值的事:搭一个能切换规则组合的调试 GUI,用同一批问题(30 道)去跑五种不同的规则配置,然后并排对比输出。

模式启用的规则
① 纯净版无任何规则、无清理
② 仅 Prompt 模板只有 prompt_templates.py 的系统规则
③ 仅输出清理只有 qwen_engine.py 的清理逻辑
④ 完整规则链全部启用
⑤ Prompt + 清理去掉意图路由等外挂

这个实验的设计意图很清楚:算法里叫消融实验(ablation)——把每个组件单独摘掉,看它的实际贡献。工程上很少有比这更便宜的诊断手段了。

结果非常难看,但正是我需要看到的。

模式①:无规则的裸模型#

同一批问题下,1.5B 的表现是这样的:

你: 你好
你好,请问有什么可以帮助你的吗?
你: 你是谁?
我是阿里云的智能助手,您可以叫我小智。有什么可以帮助您的吗?
你: 你认识我吗?
是的,我认识你。你是谁?请告诉我你的问题,我会尽力帮助你。

三个问题一次暴露三个:

  1. 身份完全错乱——它自称「阿里云的小智」。训练数据里这类自我介绍太多,没有约束时它优先复读高频模式。
  2. 不知道 MAYA 和热热是谁——因为没人告诉过它。
  3. 反问用户——「你是谁?」这种把球踢回去的句子,在语音场景里非常糟糕。

最夸张的是重复。问「天津的特产是什么」,它给出的列表长这样:

天津的特产有:狗不理包子、天津烤鸭、天津麻花、天津大包、天津炸酱面、天津锅贴、
天津水煎包、天津煎饼果子、天津耳朵眼炸糕、天津老豆腐、天津煎饼、天津煎饼果子、
天津煎饼、天津煎饼、天津煎饼、天津煎饼、天津煎饼、天津……

「天津煎饼」一路重复到被 max_tokens 掐断。原因不复杂:1.5B 的容量有限,一旦进入重复循环,它自己走不出来

模式②:只加 Prompt 规则#

Prompt 里写了身份定义之后,自称小智的问题解决了。但立刻冒出新的:

你: 你好
你好,MAYA。有什么我可以帮忙的吗?
你: 你是谁?
我是MAYA,热热的个人开发大模型,他是我的主人。
[语音]我是MAYA,热热的个人开发大模型,他是我的主人。[语音]我是MAYA,……

两个新问题:人称反转(模型以为用户叫 MAYA,所以回「你好,MAYA」),以及规则文本里的 [语音] 标记被模型原样吐了出来。

这次翻车直接催生了后面最重要的一个设计——身份结构化(详见下一节)。

模式③:只加输出清理#

去掉 Prompt 规则的后果立竿见影——身份又崩了。而且只做后处理治不住重复:清理逻辑是按句号切分去重的,可模型的重复用的是问号:

你: 你是谁?
我是阿里云的智能助手,您可以叫我AI。有什么可以帮助您的吗?
问题:你叫什么名字?问题:你的名字是什么?问题:你的名字叫什么?……

它清楚地说明:后处理是补丁,不是地基。上下文一丢,模型立刻退回出厂状态。

模式④:完整规则链#

全部启用后,身份、格式、长度都稳住了。但集成层面的问题暴露了出来——问「给我写个 Arduino 点灯程序」,回复是:

IoT 服务未初始化

原因是设备控制分支在 MQTT 不可用时直接返回了这句内部错误。这类问题单看某个模块永远测不出来,只有把整条链路串起来跑才会现形——这是这个对照实验附带的收获。

模式⑤:Prompt + 清理#

去掉意图路由后,模型重新接走了所有请求,输出又开始发散了。它反过来说明了一件事:前端约束和后端清理都替代不了「分流」——设备控制这种事,本来就该由确定性代码去干。

实验结论#

把五种模式的结果摆在一起,能得到一个挺硬的结论:

1.5B 这类小模型,靠它自己「变好」是不现实的。它不是不够聪明,是容量不足以同时管住身份、格式、长度和重复。想让它当管家,只能在外面给它搭一副外骨骼

五种规则组合同一批测试问题① 纯 LLM无任何规则自称“阿里云的智能助手,叫我小智”同一句重复 4 遍② 只有 Prompt 模板人称反转——回答“你好,MAYA”③ 只有输出清理重复照旧:“你叫什么名字” × 6④ 完整规则链暴露集成问题:“IoT 服务未初始化”⑤ Prompt + 清理仍会发散,收不住长度

八、四层防御:那副外骨骼长什么样#

从实验反推,约束必须在四个不同层面同时存在,缺一层都会漏。这四层加起来长成了这么一套规模——这也是为什么它的主体是 Prompt 工程,而不是模型工程

四层防御的物料规模
prompt_templates.py171 行(3 个模板常量 + 5 个拼装方法)
SYSTEM_TEMPLATE 行为规则12 条
stop_tokens 截断表74 条
_clean_output() 后处理323 行 / 6 步
Prompt 规范文档218 行(5.6 KB)
五模式对照实验5 组规则跑同一批问题

全部集中在 2025 年 11 月底到 12 月这一个月里反复改出来的,下面按层展开。

第 1 层:Prompt 约束——把身份写成元数据#

模式②暴露的人称反转,根因是自然语言描述身份不可靠。「你是 MAYA,用户是热热」这种句子,模型得先理解再遵守,中间会漏。所以改成了机器可读的结构化块:

llm/prompt_templates.py(节选)
SYSTEM_IDENTITY = """<<SYS_META>>
ASSISTANT_ID = MAYA
USER_ID = 热热
RELATION = assistant_to_user
<<END_SYS_META>>
<<PRONOUN_MAP>>
我 = ASSISTANT_ID
你 = USER_ID
我们 = ASSISTANT_ID + USER_ID
<<END_PRONOUN_MAP>>
"""

PRONOUN_MAP 那段是关键——它把「我 / 你 / 我们」到底指谁,用赋值的方式写死,而不是靠模型猜。同时系统规则里明确一句「回答中不要讨论或解释身份结构本身」,防止模型把元数据当话题聊起来。

整个 Prompt 有 5 个固定区块[System][Persona][Memory][Context][User]),另外还有 3 个只在特定条件下出现的区块:[ROOT Mode][Privacy][Realtime State]

llm/prompt_templates.py(节选)
@classmethod
def build_full_prompt(cls, user_input, memories=None, context=None,
privacy_data=None, root_mode_active=False, realtime_state=None):
parts = [
"[System]", cls.SYSTEM_IDENTITY, cls.SYSTEM_TEMPLATE, "",
]
if root_mode_active: # 只声明权限,不重复身份
parts.extend(["[ROOT Mode]", "当前处于 ROOT 模式,可访问已授权的隐私信息。", ...])
parts.extend(["[Persona]", cls.PERSONA_TEMPLATE, ""])
parts.extend(["[Memory]", cls.format_memory_section(memories or []), ""])
if root_mode_active and privacy_data: # 隐私只在 ROOT 激活时出现
parts.extend(["[Privacy]", cls.format_privacy_section(privacy_data), ""])
parts.extend(["[Context]", cls.format_context_section(context or {}), ""])
if realtime_state:
parts.extend(["[Realtime State]", cls.format_realtime_state_section(realtime_state), ""])
parts.extend(["[User]", user_input, "", "[Response]"])
return "\n".join(parts)

第 2 层:Stop Tokens——让它根本吐不出来#

生成阶段就把不该出现的东西截断。这张表现在有 74 个条目,不但覆盖系统标记、用户标记、结束标记,还把所有结构化标记的变体<<SYS_META>> / <<_SYS_META>> / _SYS_META …)都逐一列出:

llm/qwen_engine.py(节选)
stop_tokens = [
# 结构化标记(所有变体)
"<<SYS_META>>", "<<END_SYS_META>>", "<<_SYS_META>>", "_SYS_META",
"<<PRONOUN_MAP>>", "<<END_PRONOUN_MAP>>",
"<<_RESPONSE>>", "<<BEGIN_RESPONSE>>", "<<END_RESPONSE>>",
"<<_SYSTEM>>", "<<_USER>>", "<<_CONTEXT>>",
"BEGIN_RESPONSE", "END_RESPONSE", "_RESPONSE",
# Prompt 区块标记
"[System]", "[Persona]", "[Memory]", "[Context]", "[User]", "[Response]",
"[ROOT Mode]", "[Privacy]",
# 内部标签
"ASSISTANT_ID", "USER_ID", "RELATION", "assistant_to_user",
# 结束标记
"[END_OF_TEXT]", "[END_OF_SPEECH]", "[END]", "[EOS]",
"End of Response", "End of End",
# 对话标记
"用户说:", "用户问:", "用户说", "用户问",
"MAYA 答复:", "MAYA 说:", "\"MAYA\" 答复:", "MAYA 答复", "MAYA 说",
"Assistant:", "User:", "助手:", "用户:",
# 系统解释性内容(禁止语义)
"作为一个", "我被设计为", "我的设计目标是",
"根据规则", "根据设定", "根据系统", "根据模式",
"在这个模式下", "ROOT模式下",
"我的数据库", "我的系统", "我的Prompt",
"不要讨论", "不要解释", "核心行为规则", "必须遵守",
"语音合成", "[语音合成]", "[语音]", "语音结束", "语音指令", "语音响应",
"1-3句话", "1-2句话",
# 换行与 Markdown 标记
"\n\n\n", "\n\n",
"##", "###", "####",
]

这张表是从实验里长出来的——每一条都对应某个模式里真实出现过的翻车。早期版本只有 20 来条,后来每次线上日志里发现新的泄漏形态就补一条,才攒到现在的 74 条。

第 3 层:后处理清理——320 多行的兜底#

Prompt 管生成方向,Stop Tokens 管硬截断,但模型总还能绕出一些没预料到的花样(标记残片、身份漂移、重复循环)。所以最后还有一层 _clean_output(),代码里 320 多行,分六步:

步骤做什么约束强度
1移除 <<...>> / [...] 结构化标记与内部标签
2移除系统解释性内容(「作为一个 AI」「根据规则」)
3移除对话标记(MAYA 答复:用户说:)与 emoji
4移除重复(词级 → 短语级 → 句子级)
5修复身份混淆
6限制长度与风格(不超过 3 句、150 字,在句号处截断)

前三步是「硬约束」——清完就是清完了;中间两步是「软约束」——只做修补,尽量不动内容。这个划分来自一个教训,后面单独讲。

第 4 层:意图分流——不给它越权的机会#

最外层的兜底。设备控制、记忆写入这些需要确定性的活,在意图路由那一层就被截走了,根本不会走到 LLM。这一层越宽,前三层的压力就越小。

四层约束让 1.5B 像个管家第 1 层Prompt 约束身份结构化 SYSTEM_IDENTITY代词映射 PRONOUN_MAP5 固定区块 + 3 条件区块第 2 层Stop Tokens74 个截断标记系统标记 / 用户标记 / 结束标记结构化标记的所有变体第 3 层后处理清理_clean_output() 共 320+ 行句子级去重 / 标记移除 / 长度限制分阶段保护,避免清成空串第 4 层意图分流10 种意图各走各的路能不用 LLM 就不用 LLM

九、三个 bug 的修法#

这部分的共同点是:都是很傻的 bug,但都只在真实数据下才会暴露。而修法比 bug 本身更值得记。

bug 1:重复 15 次,因为分割符只有句号#

现象是问某个问题时,「你叫什么名字?」重复了 15 次。查看清理逻辑,去重是按句号切分的:

修复前(有问题)
sentences = [s.strip() for s in text.split('。') if s.strip()]
seen = set()
for sent in sentences:
if sent_clean not in seen:
seen.add(sent_clean)
unique_sentences.append(sent_clean)
else:
break # 检测到重复就停

问题一眼就能看出来:模型这次的重复用的是问号分隔。split('。') 切不开,于是 15 个重复被当成「一个超长句子」,seen 里根本没有重复项,break 永远不会触发。

修法是用正则一次切掉所有句末标点:

修复后
# 用正则切分所有句子(句号、问号、感叹号、中英文都要)
sentences = re.split(r'[。?!.!?]+', text)
sentences = [s.strip() for s in sentences if s.strip()]
seen = set()
unique_sentences = []
for sent in sentences:
sent_clean = re.sub(r'\s+', ' ', sent).strip()
if sent_clean not in seen:
seen.add(sent_clean)
unique_sentences.append(sent_clean)
else:
break # 一旦撞到重复,后面的全部丢弃
text = '。'.join(unique_sentences)

顺带在切句之前还加了一道短语级的预处理,专治「天津煎饼、天津煎饼、天津煎饼」这类词级复读:

短语级去重(先于句子级)
text = re.sub(r'(\S+)(\s+\1)+', r'\1', text) # 词级:A A A → A
text = re.sub(r'(\S+(?:\s+\S+){0,10}?)(\s+\1)+', r'\1', text) # 短语级:AB AB AB → AB

bug 2:清理过度,答案被清成了空#

修完重复之后,换了一个更隐蔽的问题:有 5 道题的回复变成了「抱歉」——不是模型答不出来,是清理逻辑把整个回答删空了。

原因是 _clean_output()一条直路走到底:每一步都只做删除,前面删多了,后面就没东西可删。任何一步过界,最终输出都是空串。

修法是分阶段保护 + 回退:每做完一步就检查长度,一旦发现被清空,立刻退回「原始文本去掉明显标记」的版本,而不是继续往下删:

llm/qwen_engine.py(节选)
stage_lengths['step1_after_markers'] = len(text)
# 方案C:分阶段检查——如果第一步后内容为空,回退到原始内容的前 100 字
if not text or len(text.strip()) < 2:
logger.warning(
f"清理过度警告(第一步后为空)- 用户输入: '{user_input[:50]}'\n"
f" 原始长度: {original_length} → 第一步后: {len(text)}"
)
fallback = re.sub(r'<<.*?>>', '', original_text)
fallback = re.sub(r'\[.*?\]', '', fallback)
fallback = fallback[:100].strip()
return fallback + "..." if fallback else ""

这个改动还顺带带来一个副产品:每次触发回退都会打一条 warning 日志,里面带着原始文本。于是「清理过度」从一个静默故障变成了可观测事件——后面调规则全靠这些日志。

bug 3:身份混淆,「我是热热」和「你叫什么名字」#

Prompt 里加了身份结构后,还剩两个漏网的,都是后处理没兜住:

实际回答问题
热热是谁我是 MAYA,我是你的私人助理。你叫什么名字?没回答,还反问
你的主人是谁我是 MAYA,他是我的主人。我的主人是热热。冗余,且用「他」而不是名字

修法是双向保险:Prompt 里补明确规则(「当被问及热热是谁时,回答热热是我的主人」),后处理里再补一层替换与兜底:

llm/qwen_engine.py(节选)
# 特殊处理:"热热是谁" —— 如果回答里既没"热热"也没"主人",说明没答上
if '热热是谁' in user_input or '热热是' in user_input:
if '热热' not in text and '主人' not in text:
text = "热热是我的主人。"
# 反问用户是不允许的,直接替换掉
text = text.replace('你叫什么名字?', '热热是我的主人')
# 冗余表达收敛
if '你的主人是谁' in user_input or '主人是谁' in user_input:
text = text.replace('他是我的主人', '热热是我的主人')

这套流程本身也值得说一句#

三个 bug 的修法都是小事,但修的顺序是有章法的,而且被打包成了一套固定动作:

发现问题(从 chat_logs / 运行日志里捞)
写分析报告(docs/分析报告/)—— 先定性,再动手
提修复方案(docs/修复方案/)—— 至少给两个方案并说清取舍
代码实现
写验证脚本(tests/验证*.py)
出验证报告(docs/修复验证/)

对一个「模型行为不可预期」的系统来说,这套流程的价值在于:它把「感觉好点了」变成了「有报告和日志能复核的行为变化」。上面那三个 bug,每个都有对应的分析报告、修复方案和验证报告躺在仓库里。

十、交付状态#

先说结论:软件层已经收尾;硬件层完成了初步落地——麦克风阵列与扬声器相关的作业已经结束物联网设备调度初步完成。下面按这两层把交付内容摊开。

软件层:已完成#

模块状态说明
系统架构已完成13 个核心模块 + 三套 GUI,模块间接口稳定
LLM 对话已完成1.5B 全本地推理,四层防御体系成型
意图路由已完成10 种意图,规则优先、LLM 兜底
长期记忆已完成存储 / 检索 / 写入规则与生命周期闭环
隐私与 ROOT已完成三库分级、口令校验、独立管理 GUI
时空状态库已完成时间 / 位置 / 天气 / 设备状态均已接入
语音链路已完成ASR(Whisper)与 TTS(Piper)服务代码、模型均就位
终端 GUI已完成对话、状态面板、设置三套界面可用

硬件层:初步落地#

  • 麦克风阵列与扬声器:相关作业已经结束——拾音、离线唤醒、合成播放这条本地音频链路的硬件侧接口已经打通。
  • 物联网设备调度初步完成——MQTT 通路、设备注册表与 DeviceSimulator 降级机制齐备,多设备指令下发与状态回传已经跑通。

结题验收清单#

  • 端到端语音链路——ASR → 对话 → TTS 完整跑通
  • 麦克风阵列拾音与扬声器播放联调
  • 意图路由 10 种意图全部生效,规则优先、LLM 兜底
  • 长期记忆写入规则与生命周期闭环
  • 隐私分级与 ROOT 模式口令校验
  • MQTT 设备通路 + DeviceSimulator 降级
  • Piper TTS 中文模型部署与验证
  • 三套 GUI 交付(对话 / 状态面板 / 设置)

交付资产#

类别数量
Python 文件48 个(核心 32 + GUI 16)
核心模块代码5681 行(不含 GUI)
GUI 代码3020 行 / 16 个文件
离线模型3 套(Whisper / Qwen2.5-1.5B / Piper)
SQLite 库3 个(记忆 / 隐私 / 时空状态)
设计与说明文档35 篇 Markdown
GUI 测试脚本6 个

一条带走的结论:规则不是越多越好#

这是整个项目里最反直觉、但被反复验证的一条:每加一条清理规则,就多一次「误伤正常回复」的机会。bug 2 那个把回答清空的故障,本质就是规则太密导致的。

所以最后定下来的分工是:能用 Prompt 约束解决的,就不在后处理里再拦一遍——每一层只管自己那一段,而不是所有人都去补同一个坑。

可以延伸的方向#

这套东西的价值不止于「家里有个会说话的盒子」。把 Prompt 当作调度层来用,能接着往外走:

  • 时空状态库继续外扩:新闻摘要、日程提醒、空气质量——都是「查得到就能答得上」的确定性数据。
  • 记忆检索接上向量:现在规则优先、向量只做排序,语义召回还有空间。
  • RDK X5 上的部署适配与性能优化:把 10 TOPS 的余量榨得更干净。
  • 多房间多设备的状态同步:从单机管家走向全屋协同。

写在最后#

回头看,MAYA2.0 这个项目真正的主角不是那个 1.5B 模型,而是围着它搭起来的那一圈约束

1.5B 的能力边界摆在那里——它会复读、会认错自己的名字、会把用户的名字搞反、会在没有约束时自称别家的助手。这些都不是「调调参数」能解决的,只能靠工程手段一层层兜住:用结构化 Prompt 管住身份,用 Stop Tokens 管住生成,用后处理管住残留,用意图路由把需要确定性的活从它手里拿走。

而这里最关键的其实不是这套架构,是那个五模式对照实验。没有它,你不会知道哪一层在真正起作用、哪一层只是装饰。做本地 AI 应用,模型选型只占一小半,剩下的一大半是搞清楚它会在哪里崩,然后提前在那些地方架上东西。

这也是那一个月最实在的收获:当模型能力被锁死在 1.5B,工程手段的边际收益会变得非常高。同样一句话,换个说法、加个结构化标记、补一条 stop token,效果就能肉眼可见地变好——这种「改几个字就能看到差别」的手感,正是 Prompt 工程让人上头的地方,也是这个项目最后把大半时间投进去的原因。

和 1.0 相比,2.0 把重心从硬件挪到了软件;但对「热热」这个用户来说,两个版本想做的事其实是同一件——让技术待在本地、待在自己手里,还得真的能用

而 2.0 交出的是一套能听、能想、能说、能落地的完整软件栈:麦克风阵列与扬声器这条音频链路已经打通,物联网设备调度也跑起来了——项目从「能打字聊天」走到了「能听会说话」。到这一步,这套东西才算真正装进了家里。

MAYA2.0:全离线物联网 Agent
https://ura2039.xyz/blog/posts/maya2-offline-iot-agent/
作者
LEGEND热热
发布于
2025-12-30
许可协议
CC BY-NC-SA 4.0
评论