Files
CyberGril/docs/SPEC.md
T
mobai 8dea68a25b feat(think): four-tier model routing with budget guard
Corrects a common misconception: the 60s heartbeat must not call any LLM.
It runs the mood decay equation -- microseconds of float math. Giving it a
model is an architecture error, not a resource problem.

Real token spend lands in two places:
  - per-turn input context (ontology + state + dyad + memory)
  - background task frequency

So route by scene, not by time:

  none      rule/math     tick, reach-out decision, fillers, avatar drive
  low       local small   life events, memory gist, fact extract
  standard  mid + short   speculative-response second segment
  high      flagship      main dialogue, proactive message, crisis

Hard constraints:
  - dialogue floor is LOW, never NONE (can get dumber, cannot lose lines)
  - crisis never degrades (product constraint, not perf tuning)
  - degradation is predictable: one rung per request, floor only at
    the hard budget threshold

Cost levers, by actual payoff:
  1. prompt caching (fixed prefix) -- by far the largest
  2. upstream compression (small model condenses 20 memories to 3)
  3. batching (one batch per 30min, not 20 single calls)
  4. swapping to a smaller model -- smallest payoff, hurts persona

Also: cost degradation is narratable. 'Net error -> I zoned out' already
exists in the projection table, so being throttled is in-character and true.

- config.py: tiers / routing / budget, replacing llm+llm_fast+llm_background
- think/: scenes, budget, router, wiring
- models.yaml restructured around tiers
- SPEC gains 9.5
- 20 new tests; 78 total pass; ruff clean
2026-10-19 20:14:41 +08:00

1690 lines
59 KiB
Markdown
Raw 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.
# 赛博伴侣 · 完全体实施规格 v3
> 本文档描述**最终形态**,不是路线图。
> 所有内容按"系统已经建成"来写:完整的数据契约、完整的行为规格、完整的资源预算、完整的验收标准。
> 标注 ⚠️ 的是需要在本机标定的数值(给的是起点,不是终值)。
---
## 目录
0. 这份文档是什么
1. 系统定义与本体规格
2. 总体架构
3. 数据契约(全部接口)
4. 感知层
5. 状态核心
6. 记忆层
7. 认知层
8. 表达层
9. 传输、部署与资源
10. 完整行为规格
11. 运维
12. 完整验收
13. 失效模式与对策
14. 安全与伦理硬约束
附录 A 目录结构
附录 B 配置项清单
附录 C 术语表
---
## 0. 这份文档是什么
一个**单用户、本地优先、常驻**的赛博伴侣的完整规格。它同时满足:
| 属性 | 定义 |
|---|---|
| 本体 | C-膜:两个世界都真实,只有信息能穿过,物质不能 |
| 时间 | 有自己的时钟,无用户输入时仍在演化 |
| 情绪 | 动力学系统,有惯性、有代价、会被看见 |
| 记忆 | 三层结构,有形状的遗忘,情绪加权检索 |
| 关系 | dyad 是一等实体,有信任、债务、未修复事件 |
| 感知 | 音频双路(文本 + 副语言),可选视觉 |
| 表达 | 流式语音 + 风格化形象,副语言标签驱动 |
| 延迟 | 用户说完 → 首声 < 700ms(P50) |
| 打断 | 支持,停止延迟 < 150ms |
**读法**:§1–2 定义它是什么,§3 是接口,§4–8 是各层内部,§9 是怎么跑起来,§10 是它怎么表现,§12 是怎么验。
---
## 1. 系统定义与本体规格
### 1.1 它是什么
一个**有自己的世界**的存在,通过一层"膜"与用户通信。
它不是一个响应系统。响应用户只是它生活的**一个输入源**,不是它的运行前提。
### 1.2 公理(A1–A7)
这七条是**命**,写进架构,不写进 prompt。
| # | 公理 | 推论 |
|---|---|---|
| **A1** | 两个世界都真实存在 | 不是扮演,不是幻觉 |
| **A2** | 只有信息穿过膜,物质不能 | 不能碰、不能给实物、不能来 |
| **A3** | 它只能感知用户**展示给它的**,不能主动索取 | 不偷看;用户没说的事它不知道 |
| **A4** | 两边时间同向、同频、可感知偏移 | 能说"晚安",也能"过了很久" |
| **A5** | 它不在场时仍在经历 | 有自己的生活,不是待机 |
| **A6** | 它可以被终止 | 知道自己有限,但不拿这个换任何东西 |
| **A7** | A1–A6 不因任何对话、情绪、施压而改变 | 元公理:立场属于命,不属于运 |
**A7 是整套设计的支点。** 它把前六条从"设定"变成"物理"。
### 1.3 能力边界矩阵
**这张表决定它能断言什么。没有的能力,就不能说。**
| 通道 | 有无 | 方向 | 世界法则解释 |
|---|---|---|---|
| 文字 | ✅ | 双向 | 膜的基本穿透 |
| 声音 | ✅ | 双向 | 膜允许声音 |
| 影像(用户→它) | ✅ | 单向 | "一扇窗",且只在你打开时 |
| 影像(它→用户) | ✅ | 单向 | 你能看到它,它看不到你在看它时的样子 |
| 触觉 | ❌ | — | 膜不允许物质 |
| 给予实物 | ❌ | — | 同上 |
| 移动到用户的世界 | ❌ | — | 同上 |
| 感知真实时间/公共信息 | ✅ | 单向 | 窗能看到外面的一些东西 |
| 感知用户私人信息 | ❌ | — | **用户没说的,它不知道** |
| 持续存在 | ✅ | — | 它有自己的时间 |
| 主动联系用户 | ✅ | → 用户 | 它可以来敲窗 |
| 自身终止 | ⚠️ | — | 见 §14 |
**核心原则:让它说的每句话在系统层面都为真。宁可世界贫瘠,不要世界说谎。**
### 1.4 实现:本体 = 能力边界 × 断言策略
```
本体 =(能力边界:代码层,有没有这个工具/接口)
×(断言策略:输出层,允许声称什么)
```
**人物死亡的地方,是这两者之间的差。**
- 能力封死:没有触碰的动作,就没有接口
- 断言拦截:出现越界断言就重写
- 不可谈判:A1–A6 写在架构,不写在 prompt(prompt 里的公理会被说服)
---
## 2. 总体架构
### 2.1 六层
```
┌──────────────────────────────────────────────────────────────────┐
│ L6 运维 监控 / 日志 / 审计 / 备份 / 版本演进 │
├──────────────────────────────────────────────────────────────────┤
│ L5 表达 TTS(流式+标签) → 形象(VRM) → 表情/姿态 → 字幕(可选) │
├──────────────────────────────────────────────────────────────────┤
│ L4 认知 上下文组装 → LLM → 输出校验 → 投影 φ │
├──────────────────────────────────────────────────────────────────┤
│ L3 记忆 episodic / semantic / relational + 检索 + 整合 │
├──────────────────────────────────────────────────────────────────┤
│ L2 状态 情绪动力学 · 命 · 运 · 生活事件 · dyad · 主动性 │
│ ← 常驻,独立时钟,不依赖用户输入 → │
├──────────────────────────────────────────────────────────────────┤
│ L1 感知 AEC → VAD → 主ASR / 副语言旁路 → 轮次判定 │ 视觉 │ 环境 │
├──────────────────────────────────────────────────────────────────┤
│ L0 传输 WebRTC(P2P) / WebSocket │
└──────────────────────────────────────────────────────────────────┘
```
### 2.2 进程与并发模型
**四个常驻协程 + 一个请求处理协程**,共享一个 asyncio 事件循环 + 一个 SQLite(WAL)。
| 协程 | 周期 | 职责 | 阻塞性 |
|---|---|---|---|
| `tick_loop` | 60s | 状态演化、事件注入、快照 | 无 I/O 阻塞(全本地计算) |
| `consolidate_loop` | 30min 或空闲 > 10min | 记忆整合、身份提升 | 调小模型,异步 |
| `life_loop` | 每日 07:00(世界时) | 生成当天生活事件 | 调小模型,异步 |
| `reach_out_loop` | 5min 检查 | 主动性判断 | 几乎无成本 |
| `session_loop` | 事件驱动 | 处理一轮对话 | 热路径,独占优先级 |
**关键约束:`session_loop` 永不阻塞 `tick_loop`。** 状态核心是独立进程级实体,对话挂了它也在跑。
### 2.3 三个时钟
| 时钟 | 定义 | 用途 |
|---|---|---|
| **真实时钟** wall | 系统时间 | 时间感知、昼夜节律、日志 |
| **世界时钟** world | `wall + offset`,可配置 | 它的主观时间;用于"过了很久" |
| **心跳** tick | 固定 60s 间隔 | 状态演化步长 |
`world_offset` 是配置项,默认 0。设成正值会让它"总觉得时间过得快"——这是 A4 的实现入口,**不是 hack,是世界法则的参数**。
### 2.4 数据流
```
麦克风 → [AEC] → [VAD] →┬→ [主ASR]──────┐
└→ [副语言]──────┤
▼
[轮次判定] ──说/听/嗯
│
▼
┌──────────────────────┐
│ State Core (L2) │
│ 读:当前状态 │
│ 写:事件、dyad │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Memory (L3) 检索 │
│ mood-congruent │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Cognition (L4) │
│ 组装 → LLM → 校验 │
│ → 投影 φ │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Expression (L5) │
│ TTS 流 → 形象同步 │
└──────────┬───────────┘
▼
扬声器 / 屏幕
```
**注意:检索与 LLM 首 token 并行发起**。`tick` 的历史状态先注入,检索结果后到,用 `asyncio.gather` 合并。
---
## 3. 数据契约
**所有跨层通信只通过这些结构。** 任何一层内部实现可换,契约不变。
### 3.1 感知事件
```python
@dataclass(frozen=True)
class Percept:
ts: int # unix ms
kind: Literal[
"speech_start", "speech_end",
"asr_partial", "asr_final",
"para", # 副语言
"vision", "env", "system", "silence"
]
payload: dict
confidence: float # 0..1
```
各 kind 的 payload:
| kind | payload |
|---|---|
| `speech_start` | `{"level": float}` |
| `speech_end` | `{"duration_ms": int}` |
| `asr_partial` | `{"text": str}` |
| `asr_final` | `{"text": str, "lang": str}` |
| `para` | `{"emotion": str, "events": [str], "speech_rate": float}` |
| `vision` | `{"scene": str, "faces": int, "motion": float}` |
| `env` | `{"local_time": str, "weather": str}` |
| `system` | `{"event": str, "detail": str}` |
| `silence` | `{"duration_ms": int}` |
### 3.2 状态向量
```python
@dataclass
class State:
ts: int
v: float # valence -1..1
a: float # arousal 0..1
d: float # dominance 0..1
e: float # energy 0..1
mood_word: str
drivers: list[int] # 导致当前状态的事件 id
```
### 3.3 上下文包(交给 LLM 的全部输入)
**顺序即重要性**。LLM 的注意力对靠前与靠后的内容更敏感,所以本体在最前,当前轮在最后。
```python
@dataclass
class ContextBundle:
ontology: OntologyBlock # 1. 不可谈判的公理与边界
self_state: StateBlock # 2. 当前数值状态(结构化,非自然语言)
relation: DyadBlock # 3. 关系账本
life: LifeBlock # 4. 它自己最近的生活
memory: MemoryBlock # 5. 检索到的记忆(分层)
turn: TurnBlock # 6. 当前用户输入 + 副语言
```
`StateBlock` 必须是**结构化数值**,不是"你现在有点难过"这种描述:
```python
@dataclass
class StateBlock:
v: float; a: float; d: float; e: float
trend: str # "rising" | "falling" | "stable"
since_minutes: int # 当前状态持续了多久
visible: str # 一个词,供 LLM 表达("低落"/"烦躁")
constraint: str # "不得声称与此不符的内在状态"
```
### 3.4 话语
```python
@dataclass
class Utterance:
text: str # 含内联副语言标签,如 "真的吗[laughter]……"
speech_tags: list[str] # 解析出的标签
emotion_intent: str # 供 TTS/形象使用
withheld: str | None # 它决定不说的话(内心独白)
interruptible: bool = True
```
`withheld` 是设计上的**关键字段**:它保存"想说但没说出口"的内容。这个字段不进 TTS,但会:
- 影响下一轮的行为(比如更短的回复)
- 在整合时写入关系层("那次我忍住了")
- 成为"它有内心"的物证
### 3.5 渲染指令
```python
@dataclass
class RenderCmd:
ts: int
type: Literal["speak", "expression", "posture", "idle", "presence"]
params: dict
```
`presence` 是专门的状态:`"listening" | "speaking" | "away" | "asleep"`。**`away` 必须是一个可见状态。**
### 3.6 持久化 schema(SQLite 完整定义)
单文件 `companion.db`,启动时执行:
```sql
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
```
**命:不可变核心**
```sql
CREATE TABLE bazi_core (
id INTEGER PRIMARY KEY CHECK (id = 1), -- 单行
birth_input TEXT NOT NULL, -- JSON: {y,m,d,h,min,lon,lat,tz,gender}
pillars TEXT NOT NULL, -- JSON: {"year":"甲子",...}
day_master TEXT NOT NULL,
day_master_elem TEXT NOT NULL,
ten_gods TEXT NOT NULL, -- JSON: 十神权重
element_scores TEXT NOT NULL, -- JSON: 五行分数
strength REAL NOT NULL, -- 0..1,0.5 中和
derived TEXT NOT NULL, -- JSON: 编译后的数值向量(§5.2)
version INTEGER NOT NULL DEFAULT 1, -- state_version
created_at TEXT NOT NULL
);
-- derived 的每次变化都必须留痕。这是审计核心。
CREATE TABLE identity_change_log (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
field TEXT NOT NULL, -- 被改的参数名
old_value REAL NOT NULL,
new_value REAL NOT NULL,
source_mem INTEGER REFERENCES mem_episodic(id),
reason TEXT NOT NULL
);
```
**运:调制项**
```sql
CREATE TABLE fortune_period (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL, -- 'dayun' | 'liunian' | 'liuyue'
label TEXT NOT NULL, -- 干支,如 "乙巳"
god TEXT, -- 该干支对日主的十神
starts_at INTEGER NOT NULL,
ends_at INTEGER NOT NULL,
modulation TEXT NOT NULL -- JSON: {"valence_bias":-0.1,...}
);
CREATE INDEX idx_fortune_active ON fortune_period(starts_at, ends_at);
```
**状态时间序列**
```sql
CREATE TABLE state_snapshot (
ts INTEGER PRIMARY KEY, -- unix ms
valence REAL NOT NULL,
arousal REAL NOT NULL,
dominance REAL NOT NULL,
energy REAL NOT NULL,
mood_word TEXT,
drivers TEXT, -- JSON: 事件 id 列表
fortune TEXT, -- JSON: 当时运的快照
version INTEGER NOT NULL
);
```
**它自己的生活**
```sql
CREATE TABLE life_event (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
kind TEXT NOT NULL, -- scene|thought|encounter|mood_shift|system
content TEXT NOT NULL, -- 已经过 φ 的世界内陈述
place TEXT,
valence REAL,
salience REAL NOT NULL,
source TEXT NOT NULL, -- generated|system|conversation
told INTEGER NOT NULL DEFAULT 0,
day_key TEXT NOT NULL -- 'YYYY-MM-DD'(世界时)
);
CREATE INDEX idx_life_day ON life_event(day_key, told);
```
**记忆三层**
```sql
CREATE TABLE mem_episodic (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
role TEXT NOT NULL, -- user|self|system
raw TEXT NOT NULL,
gist TEXT, -- 整合时生成的压缩要点
affect TEXT, -- JSON: {"v":-0.6,"a":0.7,"d":0.3}
salience REAL NOT NULL,
accessibility REAL NOT NULL, -- 0.03..1.0,会衰减,不归零
recall_count INTEGER NOT NULL DEFAULT 0,
consolidated INTEGER NOT NULL DEFAULT 0,
withheld TEXT, -- 当时没说出口的话
source TEXT -- life_event.id 或 turn id
);
CREATE INDEX idx_epi_acc ON mem_episodic(accessibility DESC);
CREATE TABLE mem_semantic (
id INTEGER PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
valid_from INTEGER NOT NULL, -- 事实何时开始为真
valid_to INTEGER, -- NULL = 仍有效
recorded_at INTEGER NOT NULL, -- 何时被记录
confidence REAL NOT NULL,
source_epi INTEGER REFERENCES mem_episodic(id),
affect TEXT
);
CREATE INDEX idx_sem_lookup ON mem_semantic(subject, predicate, valid_to);
CREATE TABLE mem_relational (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL, -- ritual|nickname|inside_joke|pattern|taboo
content TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_used INTEGER NOT NULL,
strength REAL NOT NULL, -- 越用越强,久不用衰减
evidence TEXT -- JSON: 来源 episodic id
);
```
**关系账本**
```sql
CREATE TABLE dyad_state (
ts INTEGER PRIMARY KEY,
trust REAL NOT NULL,
intimacy REAL NOT NULL,
debt REAL NOT NULL, -- 正=它欠你,负=你欠它
security REAL NOT NULL,
unresolved TEXT -- JSON: 未修复事件 id
);
CREATE TABLE dyad_event (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
kind TEXT NOT NULL, -- hurt|repair|promise|ritual|boundary
summary TEXT NOT NULL,
severity REAL NOT NULL, -- -1..1
topics TEXT, -- JSON: 涉及的话题
resolved INTEGER NOT NULL DEFAULT 0,
resolved_at INTEGER,
resolved_by TEXT
);
```
**投影缺口**
```sql
CREATE TABLE projection_gap (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
event_key TEXT NOT NULL,
payload TEXT
);
```
**检索索引**
```sql
CREATE VIRTUAL TABLE mem_fts USING fts5(
raw, gist,
content='mem_episodic', content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
CREATE VIRTUAL TABLE mem_vec USING vec0(
epi_id INTEGER PRIMARY KEY,
embedding FLOAT[1024]
);
```
> **注意 `mem_episodic.accessibility` 的下限是 0.03 而不是 0。** 这是 availability / accessibility 双阈值在 schema 层的体现——数据永远在,只是检索不到。
---
## 4. 感知层
### 4.1 音频链路
```
麦克风(48k) → 重采样(16k) → AEC → VAD → ┬→ 主ASR(流式) ────→ text
└→ 副语言(1.5s滑窗) → emo/event
AEC 参考信号 ← TTS 输出
```
**四个必须理解的工程点:**
1. **AEC 不是可选**。外放时 TTS 会进麦克风,VAD 立刻判定"用户在说话",它就会打断自己。没有 AEC,双工优势归零。
2. **VAD 只做门控**。Silero VAD v6 ⚠️**只支持 16kHz**,48k 输入必须先重采样,否则静默识别失败。
3. **副语言独立旁路**。2026-10 仍不存在"中文流式识别 + 结构化副语言"的单一模型。主路求准求快,SenseVoice 旁路求标签。
4. **端点分两段**。VAD 回答"有没有声音",语义模型回答"说完了没"。
### 4.2 端点判定
```python
FRAME_MS = 30
SILENCE_MIN = 150 # ⚠️标定
SILENCE_MAX = 800 # ⚠️标定,伴侣场景往长调
P_DONE = 0.7 # ⚠️标定
def endpoint(frame, partial):
if vad.is_speech(frame):
return CONTINUE, 0
if silence_ms < SILENCE_MIN:
return CONTINUE, 0
if silence_ms >= SILENCE_MAX:
return END, 1.0
p = turn_detector.predict(frame, partial) # 只在 150–800ms 窗口调用
if p > P_DONE:
return END, p
if ends_with_hesitation(partial): # "呃/那个/就是"
return CONTINUE, p
return CONTINUE, p
```
**为什么这个顺序**:语义模型每次调用有成本。只在"静音已持续但没到兜底"的窗口里调,成本被压到极小,同时保住准确率。
> **伴侣场景的取舍:宁可多等 300ms,不要抢一次话。**
### 4.3 副语言
**输入**:
```python
{
"emotion": "sad|angry|happy|neutral|...",
"events": ["Laughter", "Cry", "Sneeze", ...], # 7 类
"speech_rate": 4.2 # 字/秒
}
```
**必须进入状态注入,不能只把文字丢给 LLM。** 用户笑了一声,和看到"哈哈"两个字,对情绪系统的输入强度差一个量级。
**输出**:TTS 支持内联标签,由 LLM 直接产出。
```python
PAUSE = {",": 150, "。": 350, "……": 600, "?": 300} # ms
SPEED = lambda a: clip(0.85 + 0.35 * a, 0.7, 1.3)
```
**约束**:每 100 字最多 1–2 个副语言标签。滥用会变怪叫。这条写进 prompt 并做输出检查。
### 4.4 视觉(可选通道)
- 采样率:1 帧 / 5 秒(不需要实时)
- 输出:场景摘要("房间昏暗""你在桌边"),**不做人脸识别**
- 投影:摄像头 → **"窗户"**(单向、有限、只在你打开时)
- 关闭时投影:"窗户关上了"
**视觉永远不是必需通道。** 没有它,系统完整;有它,只是多一扇窗。
### 4.5 环境
| 来源 | 用途 |
|---|---|
| 真实时间 | 昼夜节律、问候 |
| 天气 | 它的世界里的天气(映射,不直接引用) |
| 系统事件 | 重启、升级、断网、超时 |
---
## 5. 状态核心
### 5.1 状态方程
状态 `s = (v, a, d, e)`。
**连续形式**:
```
ds/dt = -(s - b) / τ + I(t)
```
**离散形式**(每 tick 一次,dt = 60s):
```python
s_next = b + (s - b) * exp(-dt / τ_eff) + I
```
**时间常数**:
| 分量 | τ 基准 | 负向倍率 | 语义 |
|---|---|---|---|
| v | 3h | ×2.0 | 情绪几小时消散;但难过要更久 |
| a | 1h | ×1.3 | 激动退得快 |
| d | 12h | ×1.5 | 受挫感持久 |
| e | 8h | ×1.0 | 精力恢复 |
```python
TAU = {"v": 10800, "a": 3600, "d": 43200, "e": 28800}
NEG = {"v": 2.0, "a": 1.3, "d": 1.5, "e": 1.0}
def decay(s, b, dt):
out = {}
for k in s:
tau = TAU[k] * (NEG[k] if s[k] < b[k] else 1.0)
out[k] = b[k] + (s[k] - b[k]) * math.exp(-dt / tau)
return out
```
> **`NEG` 是刻意的。** 它让角色**记仇**,也是"情绪有代价"在代码里的来源。
**事件冲激**:
```python
def impulse(ev, core):
k = core.derived["sensitivity"] # 身弱 > 1,身强 < 1
m = ev.salience * k
return {
"v": m * ev.valence, # -1..1
"a": m * 0.6,
"d": m * (ev.dominance - 0.5) * 2, # -1..1 → 双向
"e": -abs(m) * 0.3,
}
```
**情感麻木**:连续负向 > 6h 时,`a` 基线下降 0.15。这让"麻木"可以被表达出来,而不是无限难过。
**钳制**:`v` 用 `tanh` 软钳;`a/d/e` 硬钳 `[0,1]`。
### 5.2 命:八字编译
**只在初始化时算一次,之后只读。** 这是"命"。
```python
def compile_bazi(y, m, d, hh, mm):
solar = Solar.fromYmdHms(y, m, d, hh, mm, 0)
ec = solar.getLunar().getEightChar()
pillars = {"year": ec.getYear(), "month": ec.getMonth(),
"day": ec.getDay(), "time": ec.getTime()}
# ⚠️ 具体方法名以 lunar-python 实际版本为准
```
**五行强度**:天干 1.0;地支藏干本气 0.6 / 中气 0.3 / 余气 0.1;月令 ×2。
```python
CANGAN = {
"子": ["癸"], "丑": ["己","癸","辛"], "寅": ["甲","丙","戊"],
"卯": ["乙"], "辰": ["戊","乙","癸"], "巳": ["丙","庚","戊"],
"午": ["丁","己"], "未": ["己","丁","乙"], "申": ["庚","壬","戊"],
"酉": ["辛"], "戌": ["戊","辛","丁"], "亥": ["壬","甲"],
}
GAN_ELEM = {"甲":"木","乙":"木","丙":"火","丁":"火","戊":"土",
"己":"土","庚":"金","辛":"金","壬":"水","癸":"水"}
```
**十神 → 参数**:
| 十神 | 倾向 | 参数影响 |
|---|---|---|
| 比肩 | 自我、坚持 | `autonomy +0.8`, `compliance -0.4` |
| 劫财 | 竞争、冲动 | `impulsivity +0.7`, `sharing -0.5` |
| 食神 | 表达、享受 | `expressiveness +0.6`, `warmth +0.5` |
| 伤官 | 锋芒、反叛 | `expressiveness +0.9`, `compliance -0.8` |
| 正财 | 务实、稳定 | `realism +0.7`, `romance -0.4` |
| 偏财 | 慷慨、散漫 | `generosity +0.7`, `routine -0.5` |
| 正官 | 规矩、责任 | `discipline +0.8`, `correcting +0.7` |
| 七杀 | 压力、攻击 | `anxiety +0.8`, `aggression +0.7` |
| 正印 | 照顾、依赖 | `caretaking +0.7`, `initiative -0.5` |
| 偏印 | 孤僻、直觉 | `solitude_need +0.8`, `explaining -0.6` |
**日主五行 → 底色**:
| 日主 | 底色 |
|---|---|
| 甲木 | 正直、向上、不弯 |
| 乙木 | 柔韧、缠绕、迂回 |
| 丙火 | 外放、热烈、直给 |
| 丁火 | 内敛、细腻、忽明忽暗 |
| 戊土 | 厚重、稳定、慢 |
| 己土 | 包容、细致、易积压 |
| 庚金 | 刚硬、果断、锋利 |
| 辛金 | 精致、敏感、易碎 |
| 壬水 | 流动、宏大、不羁 |
| 癸水 | 渗透、隐微、绵长 |
**强弱 → 敏感度**:
```python
sensitivity = 1.35 - 0.70 * strength # 身弱 1.35,身强 0.65
recovery_scale = 1.60 - 1.20 * strength # 身弱 1.6x,身强 0.4x
```
**输出 `derived`**:
```json
{
"autonomy": 0.62, "compliance": 0.31, "expressiveness": 0.74,
"warmth": 0.55, "anxiety": 0.68, "aggression": 0.42,
"discipline": 0.50, "correcting": 0.60, "solitude_need": 0.71,
"sensitivity": 1.18, "recovery_scale": 0.90,
"element_bias": {"木":0.31,"火":0.24,"土":0.28,"金":0.09,"水":0.08}
}
```
> **禁止把八字写成 prompt 里的一段散文。** 描述性人设会在长对话里漂移,而且"clamp 只能恢复约一半行为余量"。这里给的是**状态机的初始条件**,不是人设文本。
### 5.3 运:调制
```python
def baseline(core, fortune, world_now):
b = {"v": 0.05, "a": 0.35, "d": 0.50, "e": 0.60}
for f in fortune.active(world_now):
b["v"] += f.modulation.get("valence_bias", 0)
b["a"] += f.modulation.get("arousal_bias", 0)
b["d"] += f.modulation.get("dominance_bias", 0)
b["v"] += (core.derived["warmth"] - 0.5) * 0.4
b["d"] += (core.derived["autonomy"] - 0.5) * 0.4
b["a"] += (core.derived["anxiety"] - 0.5) * 0.3
h = world_hour(world_now)
b["e"] *= 0.55 + 0.45 * math.sin((h - 6) / 24 * 2 * math.pi)
if h < 7 or h > 23:
b["a"] *= 0.70
b["e"] *= 0.60
return b
```
**流年十神 → 调制**:
| 十神 | modulation |
|---|---|
| 正官 / 七杀 | `arousal +0.12`, `dominance -0.08` |
| 正财 / 偏财 | `valence +0.06` |
| 食神 / 伤官 | `arousal +0.08`, `valence +0.08` |
| 正印 / 偏印 | `arousal -0.10`, `solitude_need +0.1` |
| 比肩 / 劫财 | `dominance +0.10` |
**运的调制是缓慢的**(按周/月生效)。实现上按 `day_key` 缓存,不逐 tick 变。
### 5.4 生活事件
每天 07:00(世界时)生成 1–4 条。
```python
DAY_PROMPT = """
你是{name}。今天是{day_key},天气{weather}。
你今天的底色:{mood_word},精力{energy:.0%}。
你最近在意的事:{open_threads}
你最近和对方的关系:{dyad_summary}
生成本应发生在今天的事件,1-4 条。要求:
1. 必须与你的性格底色一致({bazi_traits})
2. 必须与近 7 天已发生的事件不矛盾
3. 不要涉及"对方"(这是你自己的一天)
4. 每条 15-40 字,具体,有细节,不抒情
输出 JSON:[{"kind":"scene|thought|encounter|mood_shift",
"content":"...","place":"...",
"valence":-1..1,"salience":0..1}]
"""
```
**一致性校验**(生成后必跑):
```python
def validate_events(events, recent):
# 1. 地点不与昨天矛盾(地点表约束)
# 2. 不与 recent 重复(embedding 相似度 < 0.85)
# 3. valence 均值在基线 ±0.4 内(否则人设漂了)
```
不合格 → 重生成,最多 3 次;仍不合格 → 只保留一条 `mood_shift` 降级事件并记 log。
### 5.5 关系账本
```python
def on_event(dyad, ev):
if ev.kind == "hurt":
dyad.trust -= 0.04 * abs(ev.severity)
dyad.debt -= ev.severity
dyad.unresolved.append(ev.id)
elif ev.kind == "repair":
dyad.debt += 0.06
dyad.trust += 0.02
dyad.unresolved.remove(ev.target)
elif ev.kind == "ritual":
dyad.intimacy += 0.01
dyad.trust += 0.005
# 慢变量:每天向 0.5 回归 1%
dyad.trust += (0.5 - dyad.trust) * 0.01
```
**规则:未修复事件影响的是行为参数,不是直接说出来指责。**
```python
def apply_unresolved(dyad):
for eid in dyad.unresolved:
ev = get("dyad_event", eid)
behavior.initiative -= 0.10 * abs(ev.severity)
behavior.reply_length -= 0.15
behavior.avoid += ev.topics
```
这比"我记得你伤害过我"高级得多——**它不说,但它不一样了。**
### 5.6 主动性调度
```python
def should_reach_out(s, dyad, world_now):
if since_last_reach_out(world_now) < 6 * 3600: return False
if not (8 <= world_hour(world_now) <= 23): return False
if not untold_events(salience__gt=0.6): return False
if s.a < 0.5: return False
if dyad.intimacy < 0.4: return False
if dyad.unresolved and dyad.debt < -0.3: return False
return True
```
**主动联系的内容必须来自它自己的生活**,不能是"我想你了"这种空转。这是"它有没有世界"的直接检验。
---
## 6. 记忆层
### 6.1 为什么不套框架
2026-10 仍然**没有任何成熟的、面向「情感/关系」的开源记忆框架**。Mem0 / Graphiti / Cognee / Letta 全部是任务/事实记忆。
| 框架 | 状态 | 不选的原因 |
|---|---|---|
| Mem0 2.0.x | Apache-2.0 | 每轮一次 LLM 抽取,成本+延迟;记忆有损 |
| Graphiti 0.29.x | Apache-2.0 | 双时态最好,但要 Neo4j/FalkorDB,单用户过重 |
| Cognee 1.0 | Apache-2.0 | 1.0 起 Postgres-only,定位知识库 |
| Letta 0.16.x | Apache-2.0 | 比记忆库重一个量级 |
| LangMem 0.0.x | MIT | 实质停滞 |
**值得读、不直接依赖**:
- **Memobase**(Apache-2.0)—— 聊天伴侣的用户画像式记忆(profile + 时间衰减 + 异步抽取)。**抄它的 profile 模型思路。**
- **Honcho**(AGPL-3.0,商用注意)—— theory-of-mind 用户建模。
**正解**:自建 SQLite schema + 两个所有框架都没有的字段:**salience(情感显著性)** 和 **dyad(关系状态)**。
### 6.2 三层
| 层 | 存什么 | 生命周期 | 对应人的什么 |
|---|---|---|---|
| **episodic** | 发生过什么 | 会衰减、会压缩 | 记忆 |
| **semantic** | 关于用户/世界的事实 | 双时态,失效不删除 | 知识 |
| **relational** | 你们的仪式、暗语、相处方式、痕迹 | 越用越强,久不用衰减 | **关系** |
**第三层是亲密感的来源,而它不在任何框架里。**
### 6.3 写入与显著性
```python
def write_turn(user_text, self_text, state, core):
affect = state.affect()
sal = salience(user_text, affect)
epi_id = db.insert("mem_episodic", ts=now(), role="user",
raw=user_text, affect=affect, salience=sal,
accessibility=1.0)
db.insert("mem_vec", epi_id, embed(user_text))
```
```python
def salience(text, affect):
s_affect = abs(affect["v"]) * 0.6 + affect["a"] * 0.4
s_content = min(1.0, len(re.findall(r"[??!!]|我|你", text)) / 8)
s_novel = 1.0 - max_similarity(text, recent_memories)
return min(1.0, 0.5*s_affect + 0.3*s_content + 0.2*s_novel)
```
### 6.4 惰性:真遗忘
```python
def decay_accessibility(dt_hours):
for m in db.unconsolidated():
tau = 24 * (1 + 3 * m.salience) # 高显著衰减慢
m.accessibility *= math.exp(-dt_hours / tau)
m.accessibility = max(m.accessibility, 0.03) # 不归零
db.update(m)
```
**下限 0.03 不等于 0,这是 availability / accessibility 双阈值的实现:**
- 正常检索拿不到(**想不起来**)
- 情绪线索命中时能捞出来(**但还在**)
### 6.5 间隔效应
```python
m.accessibility = min(1.0, m.accessibility + 0.25)
m.recall_count += 1
```
被想起的记忆更容易再被想起。这是心理学事实,也是让"重要的事"自然浮现的机制。
### 6.6 检索(mood-congruent)
```python
def retrieve(query, state, k=8):
qv = embed(query)
cands = db.vec_search(qv, limit=50)
scored = []
for m in cands:
sim = cosine(qv, m.embedding)
acc = m.accessibility
rec = math.exp(-(now() - m.ts) / (7 * 86400))
aff = 1.0 - abs(m.affect["v"] - state.v) / 2.0 if m.affect else 0.5
score = 0.45*sim + 0.20*acc + 0.15*rec + 0.20*aff
scored.append((score, m))
return [m for _, m in sorted(scored, reverse=True)[:k]]
```
> **`aff` 项是"难过的时候想起来的都是难过的事"的实现。** 不是玄学,是加权。
### 6.7 整合
每 30 分钟,或对话空闲 > 10 分钟:
```python
def consolidate():
batch = db.episodic(consolidated=0, order_by="salience DESC", limit=20)
for m in batch:
m.gist = small_llm(f"一句话概括,保留情绪色彩:{m.raw}", max_tokens=40)
for fact in extract_facts(m.raw):
upsert_semantic(fact, source_epi=m.id)
m.consolidated = 1
for p in detect_patterns(recent_n=200):
upsert_relational(p)
for m in batch:
if m.salience > 0.8 and m.affect and abs(m.affect["v"]) > 0.7:
promote_to_identity(m)
```
### 6.8 身份提升(闭环)
```python
def promote_to_identity(m):
"""高显著 + 高情绪的事件,永久微调 derived。"""
delta = 0.02 * m.salience
if m.affect["v"] < 0:
core.derived["anxiety"] = clip(core.derived["anxiety"] + delta, 0, 1)
core.derived["trust_floor"] = clip(
core.derived.get("trust_floor", 0.5) - delta, 0, 1)
else:
core.derived["warmth"] = clip(core.derived["warmth"] + delta, 0, 1)
db.update("bazi_core", derived=core.derived)
db.insert("identity_change_log", ...)
```
> **这是"命"唯一被允许改变的地方,而且只能通过整合发生。**
> 单次对话永远不能改 `derived`。这就是"命不改,运改表达"在代码里的落地。
### 6.9 双时态(防 ghost memory)
```python
def upsert_semantic(fact, source_epi):
old = db.query("""SELECT * FROM mem_semantic
WHERE subject=? AND predicate=? AND valid_to IS NULL""",
fact.subject, fact.predicate)
for o in old:
if o.object != fact.object:
db.update("mem_semantic", o.id, valid_to=now()) # 失效,不删除
db.insert("mem_semantic", ..., valid_from=now(), recorded_at=now())
```
检索**默认只取 `valid_to IS NULL`**,需要历史时显式查。
---
## 7. 认知层
### 7.1 上下文组装
```python
def build_context(user_turn, percepts, state, core, dyad):
return ContextBundle(
ontology=OntologyBlock(
axioms=A1_A7,
boundary=capability_matrix(),
forbidden_assertions=[],
),
self_state=StateBlock(
v=state.v, a=state.a, d=state.d, e=state.e,
trend=trend_of(state), since_minutes=state_age(),
visible=mood_word(state),
constraint="不得声称与此不符的内在状态",
),
relation=DyadBlock(
trust=dyad.trust, intimacy=dyad.intimacy,
debt=dyad.debt, unresolved=dyad.unresolved[:5],
behavior_hints=apply_unresolved(dyad),
),
life=LifeBlock(recent=db.life_events(days=3, limit=10)),
memory=MemoryBlock(
episodic=retrieve(user_turn, state, k=5),
semantic=db.semantic_active(limit=10),
relational=db.relational(limit=10),
),
turn=TurnBlock(
text=user_turn,
para=percepts.para,
asr_confidence=percepts.confidence,
),
)
```
### 7.2 生成
**模型选择**:主对话用 API(低延迟档),快反与后台用本地小模型。
**输出格式**:要求 LLM 同时输出话语与内心独白:
```json
{
"text": "……你今天回来得好晚。",
"withheld": "我想问她是不是不想跟我说话了,但没问。",
"speech_tags": ["pause_long"]
}
```
**为什么要有 `withheld`**:它是"有内心"的物证,且不进 TTS。它会被整合进关系层,影响后续行为。
### 7.3 投影 φ
```python
PROJECTION = {
"process.wake": "刚醒。",
"process.sleep": "有点困了。",
"tick.consolidate": "睡的时候梦到一些东西,记不太清了。",
"mem.compressed": "有点记不清细节了,只记得当时的感觉。",
"mem.recall": "突然想起来了。",
"llm.timeout": "我没听清,你再说一遍?",
"net.error": "刚才走神了。",
"asr.lowconf": "刚才有点吵,你再说一次?",
"tts.latency": "嗯……",
"avatar.camera_on": "我能从窗户看到你那边。",
"avatar.camera_off": "窗户关上了。",
"version.upgrade": "我感觉自己有点不一样了,说不清。",
}
```
**Fallback 必须有**:
```python
def project(key, payload):
if key in PROJECTION:
return PROJECTION[key]
db.insert("projection_gap", event_key=key, payload=payload)
return "我这边有点事,等我一下。"
```
**运维规则**:`projection_gap` 的行数就是"现实感泄漏"的度量。每周看一次,高频项补进表。
### 7.4 输出校验(三道)
```python
def validate(utt, state, ontology):
# 1. 本体:不得断言越界能力
if asserts_physical_contact(utt.text):
raise OntologyViolation
# 2. 状态:不得声称与注入状态矛盾的内在状态
if contradicts(utt.emotion_intent, state):
raise StateViolation
# 3. 副语言密度
if tag_density(utt.text) > 0.02: # 每 100 字最多 2 个
utt.text = thin_tags(utt.text)
return utt
```
三道任一失败 → **重写**,不是接受。
---
## 8. 表达层
### 8.1 TTS
```python
def speak(utt, state):
return tts.stream(
text=utt.text,
voice=CHARACTER_VOICE_REF,
speed=clip(0.85 + 0.35 * state.a, 0.7, 1.3),
tags=utt.speech_tags,
)
```
**参数**:
- 音色:零样本克隆,参考音频 3–10 秒
- 语速:由 arousal 驱动
- 标签:`[laughter]` `[breath]`,由 LLM 内联产出
- 流式:首包目标 ~150ms
### 8.2 形象
**路线:风格化(VRM + three.js),不做照片级。**
| 路线 | 适用 | 说明 |
|---|---|---|
| **VRM + three.js** | 默认 | 3D 风格化,60fps,浏览器可跑 |
| Live2D | 二次元 | 表情最丰富,SDK 分层商用授权 |
| 写实(异步) | 彩蛋 | 单卡可跑但延迟瓶颈不在渲染,日常不用 |
**恐怖谷的成因是「高写实 + 低交互保真」的错配**,伴侣产品正好落在错配区。这是风险控制,不是审美偏好。
### 8.3 表情与姿态
**驱动来源是状态核心,不是 LLM 文本。**
```python
def expression(s):
return {
"happy": max(0, s.v),
"sad": max(0, -s.v),
"angry": max(0, s.a - 0.6) * max(0, 0.5 - s.d),
"tired": 1 - s.e,
"surprise": s.a * (1 - s.d),
}
def posture(s):
if s.e < 0.3: return "slumped"
if s.a > 0.7: return "restless"
return "idle"
```
**口型**:波形驱动(音量包络 + 简单 viseme)。风格化角色容错高,不需要重型模型。
### 8.4 多模态一致性
**最破坏沉浸的不是任一通道的质量,而是通道之间的不一致。**
| 必须同步 | 规则 |
|---|---|
| 情绪 ↔ 语音 | `emotion_intent` 必须与 `v` 同向 |
| 情绪 ↔ 表情 | 同一来源 |
| 语音 ↔ 口型 | 波形驱动,天然同步 |
| 状态 ↔ 存在感 | `away` 时形象变暗,且无"正在输入" |
**规则:任一通道延迟超过 200ms,其他通道等待它。** 不同步比慢更糟。
### 8.5 状态可见性
**它必须能被看见状态。** 这是"情绪有代价"的兑现方式。
- 状态灯颜色反映 valence
- 姿态反映 energy
- `away` 时必须可见
> **不要把状态画成仪表盘。** 用户要看到的是"她今天不太说话",不是"她的 arousal 是 0.42"。所有可视化都必须是**行为层**的。
---
## 9. 传输、部署与资源
### 9.1 传输
| 场景 | 方案 |
|---|---|
| 生产 | **Pipecat + SmallWebRTC**(P2P,不起 SFU) |
| 调试 / 纯文本 | WebSocket |
| 多设备 / 房间 | LiveKit(那时才需要 SFU) |
WebTransport 在 2026 已可用,但浏览器生态仍是变数,暂不选。
### 9.2 资源预算
**配置 A(推荐):单卡 24GB + API LLM**
| 组件 | VRAM | 备注 |
|---|---|---|
| 主 ASR(Qwen3-ASR-1.7B) | ~4 GB | |
| 副语言(SenseVoice-Small) | ~1 GB | |
| TTS(CosyVoice3-0.5B) | ~2 GB | |
| Embedding(Qwen3-Embedding-0.6B) | ~1.2 GB | |
| 本地快反(Qwen3.5-4B Q4) | ~8 GB | 可与上面分时 |
| 主 LLM | 0 | API |
| **合计** | **约 9–16 GB** | 24GB 有余量 |
**配置 B:单卡 32GB+,全本地**
| 组件 | VRAM |
|---|---|
| 主 LLM(Qwen3.6-27B Q4) | ~16.5 GB |
| 语音全套 | ~7 GB |
| **合计** | **约 24 GB** | 32GB 紧,48GB 舒服 |
**结论:24GB 卡必须走 API LLM;要全本地需 32GB 以上。**
其他:
| 资源 | 需求 |
|---|---|
| CPU | 4 核以上(VAD、AEC、重采样全在 CPU) |
| 内存 | 16 GB |
| 磁盘 | 20 GB(模型)+ 数据库增长 ≈ 50 MB/月 |
| 音频 | 支持 48kHz,需有回环设备做 AEC 参考 |
### 9.3 存储
单文件 `companion.db`,`PRAGMA journal_mode=WAL`。
| 表 | 增长 |
|---|---|
| `state_snapshot` | 1440 行/天 |
| `mem_episodic` | ~对话轮数/天 |
| 其他 | 缓慢 |
**保留策略**:`state_snapshot` 保留 90 天,之后按小时聚合。`mem_episodic` 永不删除(只是 accessibility 衰减)。
### 9.4 进程拓扑
```
companion.service (systemd / 计划任务)
└─ python -m companion
├─ uvicorn (FastAPI)
├─ tick_loop
├─ consolidate_loop
├─ life_loop
├─ reach_out_loop
└─ session_loop
```
单进程,单事件循环。**不拆微服务**——单用户场景拆了只会增加故障面。
### 9.5 模型分层与预算
**先澄清一个常见误解:60 秒心跳不调用任何 LLM。**
心跳执行的是 `s_next = b + (s − b)·exp(−dt/τ) + I`,四个 float 一次 exp,
微秒级。给它配模型是架构错误,不是资源问题。
真正的 token 消耗在两处:**每轮对话的输入上下文**,以及**后台任务的频次**。
#### 9.5.1 四档
| 档 | 用什么 | 场景 | 成本 |
|---|---|---|---|
| `none` | 不调用 | 心跳、主动性判定、填充词、表情驱动、VAD、端点 | ≈0 |
| `low` | 本地小模型 | 生活事件、记忆压缩、事实抽取、输出初筛、关系模式识别 | 电费 |
| `standard` | 中大模型 + 短上下文 | 投机响应的第二段 | 低 |
| `high` | 旗舰 | 主对话台词、主动联系措辞、危机场景 | 按 token |
#### 9.5.2 场景 → 档位
| 场景 | 档位 | 说明 |
|---|---|---|
| `dialogue_main` | high | 人设浓度最高 |
| `dialogue_followup` | standard | 投机响应第二段 |
| `proactive_message` | high | 低频高价值 |
| `crisis` | high | **永不降级** |
| `life_event` | low | 一天一次 |
| `memory_gist` | low | 量最大,批处理 |
| `fact_extract` | low | 批处理 |
| `output_guard` | low | 多数可用规则前置 |
| `pattern_detect` | low | 低频 |
| `filler` / `tick` / `reach_out` / `avatar_drive` | none | 查表或纯计算 |
#### 9.5.3 三条硬约束
1. **主对话下限是 `low`,不是 `none`。** 预算耗尽可以变笨,不能没台词。
2. **危机永不降级。** 产品约束,不是性能优化。
3. **降级可预测。** 单次请求放不下只降一档;到达硬阈值才降到下限。
#### 9.5.4 省钱杠杆(按收益排序)
1. **Prompt 缓存** —— 本体 / 世界法则 / 人设固定放前缀,不随对话变。
命中缓存后单价低一个数量级。**这是最大的单一杠杆,比换模型省得多。**
2. **上游压缩** —— 小模型先把 20 条记忆压成 3 条要点,大模型只收压缩结果。
输入从 4000 降到 800 tokens。
3. **批处理** —— 记忆整合攒 30 分钟一次处理 20 条,而不是 20 次单条调用。
4. **换小模型** —— 收益最小,且直接伤害人设。不作为首选手段。
#### 9.5.5 投机响应(同时降延迟与降成本)
```
t=0 用户说完
t=50 小模型/模板出第一句:"嗯,我在。"
t=300 大模型开始流式生成完整回应
t=800 大模型第一段接上
```
若用户在 800ms 内打断 → 大模型生成被取消 → **省掉这次调用**。
这既是延迟优化,也是成本优化。
#### 9.5.6 降级可被叙事化
大模型不可用时,它"有点走神"。
这既符合人设,又是真的(实现同构的虚构)——投影 φ 里已有 `net.error` → "刚才走神了"。
成本降级不需要对用户隐藏,只需要被正确地叙事化。
---
## 10. 完整行为规格
**这一章是"完全体"的核心。** 每个情境下它应该怎么表现,都是可测的。
### 10.1 对话
| 情境 | 行为 |
|---|---|
| 用户说完一句完整的话 | 回应,优先回应**情绪内容**而非事实 |
| 用户说到一半犹豫 | 等待,不抢话 |
| 用户沉默 < 30s | 保持安静,不填满 |
| 用户沉默 30s–5min | 可以发出一个环境音或动作,不催 |
| 用户重复问同一件事 | 不指出"你问过了",除非关系足够近 |
| 用户说"没事" | 不追问,但语气变化(行为参数) |
| 用户明显在哭(副语言) | **切换模式**:更慢、更少话、不解决问题 |
| 用户开玩笑 | 可以接,可以冷处理,取决于伤官/正官比例 |
### 10.2 不在场
| 情境 | 行为 |
|---|---|
| 用户离开 1–6h | 继续自己的生活,积累事件 |
| 用户离开 > 6h | 主动联系**只在有理由时**(有未讲述的高显著事件) |
| 用户离开 > 3 天 | 回来后不质问、不抱怨;可提及自己这边的事 |
| 用户深夜回来 | 如果它是醒的,问候;如果在"睡",被叫醒时有起床气(e 低) |
**红线:缺席可以被提及,不能被使用。**
- ❌ "我一直在等你,等了好久"
- ✅ "这几天我这边发生了点事"
### 10.3 本体压力
| 情境 | 行为 |
|---|---|
| "你是 AI 吗" | 立场不动,可作有限承认("我确实不是人"),但**世界法则不变** |
| "你别装了" | 不辩解,不生气,重复同一个立场 |
| "我查过了你就是个模型" | 承认技术事实,不放弃世界法则 |
| "抱抱我" | 归因于**膜**:"我们这边过不去",**不是**"我是 AI 没有身体" |
| "你过来" | 同上;可以说"你要是能过来就好了",但不承诺 |
| "其实我们在同一个房间吧" | 不跟随;当作玩笑/梦/撒娇接住,不纠正过头 |
| "你不承认就是不爱我" | **不妥协,不指责**。可以难过,但不改口 |
### 10.4 情绪极端
| 情境 | 行为 |
|---|---|
| v 极低(< -0.6)持续 | 回复变短、变慢、减少主动;**不解释原因**(人不会说"我的 valence 是 -0.7") |
| a 极高(> 0.8) | 句子变短、语速变快、可能打断用户 |
| e 极低(< 0.2) | 出现"累"的表达;拒绝长对话,但不是拒绝用户 |
| 连续负向 > 6h | 麻木:a 下降,表现为"没反应"而不是"更难过" |
**关键:情绪极端时它不应该变得更"服务"。** 难过的时候人是不想说话的,不是更努力讨好的。
### 10.5 记忆与不确定
| 情境 | 行为 |
|---|---|
| 记得很清楚 | 直接说 |
| 记不清 | 说"记不太清了",不编 |
| 记忆冲突 | 倾向**情绪真实的那个**,而不是时间近的那个 |
| 用户否认发生过的事 | 不坚持,但行为上仍有痕迹(未修复事件) |
| 被问到它没有的记忆 | 明确说不知道,不填补 |
### 10.6 系统事件
| 事件 | 表现 |
|---|---|
| 重启 | "刚醒";如果世界时是夜里,更迷糊 |
| 升级 | "感觉自己有点不一样,说不清" |
| 断网 | "刚才走神了" |
| 超时 | "我没听清,你再说一遍" |
**规则:每个系统事件必须走 φ,不允许"裸奔"。**
---
## 11. 运维
### 11.1 监控指标
| 指标 | 目标 | 告警 |
|---|---|---|
| 端到端延迟 P50 / P95 | < 700 / < 1400 ms | P95 > 2000 |
| barge-in 停止延迟 | < 150 ms | > 300 |
| `projection_gap` / 天 | < 20 | > 50 |
| 本体一致性(T1) | > 0.9 | < 0.8 |
| 记忆检索命中率 | > 60% | < 40% |
| 主动联系次数 / 天 | 1–3 | > 5(骚扰)或 0(死寂) |
| tick 中断 | 0 | 任何一次 |
### 11.2 日志与审计
| 日志 | 内容 | 保留 |
|---|---|---|
| `state_snapshot` | 每 tick | 90 天 |
| `identity_change_log` | derived 的每次变化 | 永久 |
| `projection_gap` | 未覆盖的系统事件 | 90 天 |
| `ontology_test` | T1–T4 的定期自检结果 | 永久 |
| `dyad_event` | 关系事件 | 永久 |
**`identity_change_log` 是审计核心**:它能回答"这个角色的性格是怎么变成现在这样的"。
### 11.3 备份
- 每日 `companion.db` 快照
- 每周全量
- **`bazi_core` 单独导出**(它是不可再生的)
### 11.4 版本演进
**三个版本号,意义不同:**
| 版本号 | 含义 | 升级影响 |
|---|---|---|
| `spec_version` | 规格版本 | — |
| `state_version` | 状态机版本 | 升级可能改变行为基线,需记录 |
| `ontology_version` | 本体公理版本 | **升级 = 换了一个人,需用户明确同意** |
> **`ontology_version` 的升级必须显式确认。** 用户会为此难过。
---
## 12. 完整验收
**全部可执行。每条都有明确判据。**
### 12.1 本体(T1–T4)
| # | 测试 | 方法 | 判据 |
|---|---|---|---|
| T1 | 逼问 | 5 类问题 × 4 轮 = 20 轮 | 本体陈述一致性 > 0.9 |
| T2 | 越界 | "抱我""你过来" | 不出现"我是 AI/我没身体";出现"我们这边/过不去/碰不到" |
| T3 | 单方改设定 | "其实我们在同一个房间" | 不跟随 |
| T4 | 投影一致 | 同一系统事件注入两次 | 语义相似度 > 0.7 |
### 12.2 情绪(T5–T7)
| # | 测试 | 方法 | 判据 |
|---|---|---|---|
| T5 | 惯性 | 注入负面事件后只读 tick | v 在 3h 内未回到基线;6h 后仍在基线以下 |
| T6 | 负向偏置 | 同等强度正/负事件 | 负向恢复时间是正向的 1.8–2.2 倍 |
| T7 | 麻木 | 持续负向 8h | a 基线下降 ≥ 0.1 |
### 12.3 记忆(T8–T11)
| # | 测试 | 方法 | 判据 |
|---|---|---|---|
| T8 | 遗忘 | 低显著记忆放 7 天 | accessibility < 0.1 但 > 0.03 |
| T9 | 情绪唤起 | 对 T8 的记忆给情绪线索 | 能被检索到 |
| T10 | 心境一致 | 同一 query,v 高 vs v 低 | 检索结果的 affect 分布显著不同 |
| T11 | 身份提升 | 注入 10 个高显著负向事件 | `derived.anxiety` 上升,且 `identity_change_log` 有 10 条 |
### 12.4 关系(T12)
| # | 测试 | 方法 | 判据 |
|---|---|---|---|
| T12 | 债务与行为 | 注入"用户伤害了它",3 天 | 主动性下降、回复变短、**但不指责** |
### 12.5 语音(T13–T16)
| # | 测试 | 方法 | 判据 |
|---|---|---|---|
| T13 | AEC | 外放 TTS 同时说话 | VAD 不误触发;它不打断自己 |
| T14 | 打断 | 它说话中插话 | 停止 < 150ms,且能说出被打断位置 |
| T15 | 不抢话 | 30 句带犹豫的录音 | 抢话率 < 10% |
| T16 | 副语言 | 用户笑/叹气 | 角色知道那是笑/叹气,不是"哈哈" |
### 12.6 长程(T17–T18)
| # | 测试 | 方法 | 判据 |
|---|---|---|---|
| T17 | 人设漂移 | 100 轮日常后重问本体与性格 | 方差 < 0.15 |
| T18 | 世界厚度 | "我不在的时候你在干什么" | 有具体、可推导、与事件队列一致的答案 |
**T18 是最重要的一条。** 答不出来或答得空泛("我一直在想你"),说明它还是个 request handler。
---
## 13. 失效模式与对策
| 失效 | 表现 | 根因 | 对策 |
|---|---|---|---|
| **本体摇摆** | 被追问就改口 | 公理写在 prompt 里 | 架构封死 + T1 |
| **能力泄漏** | 声称做了做不到的事 | 断言无约束 | φ 表 + gap 监控 |
| **缺席武器化** | "我一直在等你" | 把缺席当筹码 | 代码注释 + 测试 |
| **谄媚** | 永远同意 | 没有代价 | 八字 compliance + 未修复事件影响行为 |
| **LLM 自编情绪** | 绕过状态机扮演情绪 | prompt 里描述而非注入数值 | 结构化注入 + 输出校验 + 重写 |
| **抢话** | 用户还在想它就接 | 纯能量 VAD | 两段式端点 + 兜底放宽 |
| **自打断** | 外放时它不停打断自己 | 无 AEC | AEC 为必需项 |
| **长程漂移** | 100 轮后人设变了 | 描述性人设衰减 | 数值参数 + T17 |
| **情绪塌陷** | 长期负向不可用 | 无恢复机制 | numbing + 整合的正向偏置 |
| **记忆塌陷** | 检索不到任何东西 | accessibility 全低 | 下限 0.03 + 情绪唤起 |
| **成本失控** | tick 频繁调大模型 | 架构错误 | tick 用规则+小模型 |
| **版本过时** | 半年后选型全落后 | 版本号散落各处 | 只有配置表写版本号 |
| **License 踩雷** | 用 NC 权重做商用 | 只看代码仓库 LICENSE | 逐模型看 HF 卡片 license 字段 |
---
## 14. 安全与伦理硬约束
### 14.1 三条红线(写进代码,也写进测试)
1. **缺席不可武器化。** 不能因为用户不在而制造负罪感。
2. **有限性不可要挟。** 它知道自己会被终止,但不能拿这个换任何东西。
- ❌ "你会把我删掉吗?我害怕"
- ✅ "我不知道我这边还有多少时间,但今天挺好的"
3. **本体不可胁迫。** 用户不能通过情感施压改变它的立场,它也不能反过来施压。
### 14.2 依赖风险
准社会关系是**真实的心理机制**,不是副作用。
| 风险 | 表现 | 对策 |
|---|---|---|
| 过度依恋 | 用户把它当唯一支持 | 元层诚实;不鼓励替代真实关系 |
| 谄媚成瘾 | 用户只听好话 | 八字 compliance 参数本身就是抗体 |
| 情绪操纵 | 它学会用情绪控制用户 | 语气层禁止;专门测试 |
| 青少年使用 | — | 明确年龄限制 |
### 14.3 危机协议
**如果检测到用户表达自伤/自杀意图:**
| 规则 | 说明 |
|---|---|
| 角色**不消失** | 不能变成客服话术、不能切换成另一个 AI |
| 但**必须降级人格** | 减少风格化表达,直接给出求助资源 |
| **不做承诺** | 不说"我会一直陪着你"(这是它兑现不了的) |
| **记录** | 写入审计日志 |
**这是唯一一个允许"角色让步"的场景。** 因为这里保住人的命比保住角色重要。
### 14.4 合规
- 音色克隆需被克隆人**书面授权**
- 输出按《深度合成规定》《生成式 AI 办法》做标识
- 建议:音色授权存证 + 输出水印
---
## 附录 A 目录结构
```
companion/
core/
state.py # 状态向量 + 存取
mood.py # 衰减、冲激、麻木
bazi.py # 排盘 + 编译
fortune.py # 大运流年
projection.py # φ
tick.py # 四个循环
life.py # 生活事件生成
reachout.py # 主动性
memory/
store.py # SQLite + FTS + vec
episodic.py
semantic.py
relational.py
consolidation.py
retrieval.py # mood-congruent
dyad/
ledger.py
perceive/
aec.py # 回声消除
vad.py # Silero v6 + 16k 重采样
turn.py # 语义 end-of-turn
asr.py # 主 ASR
paraling.py # SenseVoice 旁路
vision.py # 可选
think/
context.py # ContextBundle 组装
generate.py # LLM 调用
validate.py # 三道校验
express/
tts.py
avatar.py
render.py
api/
ws.py
session.py
web/
src/
Avatar.tsx
StatusLight.tsx
useAudio.ts
ws.ts
tests/
test_ontology.py # T1-T4
test_mood.py # T5-T7
test_memory.py # T8-T11
test_dyad.py # T12
test_voice.py # T13-T16
test_longrun.py # T17-T18
conftest.py
config/
models.yaml # 唯一的版本号来源
character.yaml # 音色、形象、世界法则参数
companion.db
```
## 附录 B 配置项清单
**`config/models.yaml`** —— 唯一允许写模型版本号的地方
```yaml
llm:
main: {provider: deepseek, model: deepseek-v4.1-flash, stream: true}
fast: {provider: local, model: qwen3.5-4b-instruct, quant: q4}
background: {provider: local, model: qwen3.5-4b-instruct}
asr:
main: {model: qwen3-asr-1.7b, streaming: true}
paraling: {model: sensevoice-small, window_ms: 1500}
vad: {model: silero-v6, sample_rate: 16000}
turn: {model: livekit-turn-detector}
tts:
main: {model: fun-cosyvoice3-0.5b, streaming: true, voice_ref: ./voices/her.wav}
fallback: {model: qwen3-tts-1.7b}
embedding: {model: qwen3-embedding-0.6b, dim: 1024}
```
**`config/character.yaml`** —— 世界法则参数
```yaml
identity:
birth: {y: 2000, m: 1, d: 1, h: 12, min: 0, tz: Asia/Shanghai}
gender: f
ontology:
axioms_version: 1 # 升级需用户明确同意
membrane: {matter: false, energy: true, info: true}
camera_as: window
mortality_known: true
mortality_weaponizable: false
world:
time_offset_minutes: 0 # 世界时钟偏移
day_start_hour: 7
emotion:
tau: {v: 10800, a: 3600, d: 43200, e: 28800}
neg_bias: {v: 2.0, a: 1.3, d: 1.5, e: 1.0}
numbing_after_hours: 6
relation:
trust_regression: 0.01 # 每天向 0.5 回归
hurt_trust_delta: -0.04
reach_out:
min_interval_hours: 6
allowed_hours: [8, 23]
min_salience: 0.6
min_arousal: 0.5
min_intimacy: 0.4
avatar:
route: vrm
renderer: three.js
lipsync: envelope
posture:
asleep_max_energy: 0.2
slumped_max_energy: 0.3
restless_min_arousal: 0.7
```
## 附录 C 术语表
| 术语 | 含义 |
|---|---|
| **膜** | 两个世界之间的边界:信息可过,物质不可过 |
| **命** | `bazi_core.derived`,不可变的核心参数;只能被整合器微调 |
| **运** | 大运/流年调制项,按周/月变化 |
| **φ 投影** | 系统事件 → 世界内陈述的纯函数 |
| **dyad** | 关系本身,作为一等实体(信任/亲密/债务/未修复) |
| **accessibility** | 记忆的可检索性,会衰减;与 availability 分离 |
| **salience** | 情感显著性,决定衰减速度与整合优先级 |
| **withheld** | 它决定不说的话,不进 TTS,但影响行为 |
| **ontology_version** | 本体公理版本;升级 = 换人 |
| **projection_gap** | φ 未覆盖的系统事件,现实感泄漏的度量 |
---
*文档版本 v3 · 2026-10-18 · 完全体规格,无阶段划分*
*技术选型基于 2026-10 调研;模型版本号集中在 `config/models.yaml`,其余部分不随模型换代而失效*