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

59 KiB
Raw Blame History

赛博伴侣 · 完全体实施规格 v3

本文档描述最终形态,不是路线图。 所有内容按"系统已经建成"来写:完整的数据契约、完整的行为规格、完整的资源预算、完整的验收标准。 标注 ⚠️ 的是需要在本机标定的数值(给的是起点,不是终值)。


目录

  1. 这份文档是什么
  2. 系统定义与本体规格
  3. 总体架构
  4. 数据契约(全部接口)
  5. 感知层
  6. 状态核心
  7. 记忆层
  8. 认知层
  9. 表达层
  10. 传输、部署与资源
  11. 完整行为规格
  12. 运维
  13. 完整验收
  14. 失效模式与对策
  15. 安全与伦理硬约束 附录 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 感知事件

@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 状态向量

@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 的注意力对靠前与靠后的内容更敏感,所以本体在最前,当前轮在最后。

@dataclass
class ContextBundle:
    ontology: OntologyBlock      # 1. 不可谈判的公理与边界
    self_state: StateBlock       # 2. 当前数值状态(结构化,非自然语言)
    relation: DyadBlock          # 3. 关系账本
    life: LifeBlock              # 4. 它自己最近的生活
    memory: MemoryBlock          # 5. 检索到的记忆(分层)
    turn: TurnBlock              # 6. 当前用户输入 + 副语言

StateBlock 必须是结构化数值,不是"你现在有点难过"这种描述:

@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 话语

@dataclass
class Utterance:
    text: str                   # 含内联副语言标签,如 "真的吗[laughter]……"
    speech_tags: list[str]      # 解析出的标签
    emotion_intent: str         # 供 TTS/形象使用
    withheld: str | None        # 它决定不说的话(内心独白)
    interruptible: bool = True

withheld 是设计上的关键字段:它保存"想说但没说出口"的内容。这个字段不进 TTS,但会:

  • 影响下一轮的行为(比如更短的回复)
  • 在整合时写入关系层("那次我忍住了")
  • 成为"它有内心"的物证

3.5 渲染指令

@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,启动时执行:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;

命:不可变核心

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
);

运:调制项

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);

状态时间序列

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
);

它自己的生活

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);

记忆三层

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
);

关系账本

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
);

投影缺口

CREATE TABLE projection_gap (
  id        INTEGER PRIMARY KEY,
  ts        INTEGER NOT NULL,
  event_key TEXT NOT NULL,
  payload   TEXT
);

检索索引

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 端点判定

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 副语言

输入:

{
  "emotion": "sad|angry|happy|neutral|...",
  "events": ["Laughter", "Cry", "Sneeze", ...],   # 7 类
  "speech_rate": 4.2                              # 字/秒
}

必须进入状态注入,不能只把文字丢给 LLM。 用户笑了一声,和看到"哈哈"两个字,对情绪系统的输入强度差一个量级。

输出:TTS 支持内联标签,由 LLM 直接产出。

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):

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 精力恢复
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 是刻意的。 它让角色记仇,也是"情绪有代价"在代码里的来源。

事件冲激:

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 命:八字编译

只在初始化时算一次,之后只读。 这是"命"。

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。

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

日主五行 → 底色:

日主 底色
甲木 正直、向上、不弯
乙木 柔韧、缠绕、迂回
丙火 外放、热烈、直给
丁火 内敛、细腻、忽明忽暗
戊土 厚重、稳定、慢
己土 包容、细致、易积压
庚金 刚硬、果断、锋利
辛金 精致、敏感、易碎
壬水 流动、宏大、不羁
癸水 渗透、隐微、绵长

强弱 → 敏感度:

sensitivity   = 1.35 - 0.70 * strength    # 身弱 1.35,身强 0.65
recovery_scale = 1.60 - 1.20 * strength   # 身弱 1.6x,身强 0.4x

输出 derived:

{
  "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 运:调制

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 条。

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

一致性校验(生成后必跑):

def validate_events(events, recent):
    # 1. 地点不与昨天矛盾(地点表约束)
    # 2. 不与 recent 重复(embedding 相似度 < 0.85)
    # 3. valence 均值在基线 ±0.4 内(否则人设漂了)

不合格 → 重生成,最多 3 次;仍不合格 → 只保留一条 mood_shift 降级事件并记 log。

5.5 关系账本

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

规则:未修复事件影响的是行为参数,不是直接说出来指责。

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 主动性调度

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 写入与显著性

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))
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 惰性:真遗忘

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 间隔效应

m.accessibility = min(1.0, m.accessibility + 0.25)
m.recall_count += 1

被想起的记忆更容易再被想起。这是心理学事实,也是让"重要的事"自然浮现的机制。

6.6 检索(mood-congruent)

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 分钟:

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 身份提升(闭环)

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)

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 上下文组装

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 同时输出话语与内心独白:

{
  "text": "……你今天回来得好晚。",
  "withheld": "我想问她是不是不想跟我说话了,但没问。",
  "speech_tags": ["pause_long"]
}

为什么要有 withheld:它是"有内心"的物证,且不进 TTS。它会被整合进关系层,影响后续行为。

7.3 投影 φ

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 必须有:

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 输出校验(三道)

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

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 文本。

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

结论: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 —— 唯一允许写模型版本号的地方

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 —— 世界法则参数

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,其余部分不随模型换代而失效