# NT-Sine

本地部署的中文情感陪伴模型。单一角色、单一用途、全离线运行。

| 项目属性 | 说明 |
|---|---|
| 项目形态 | 本地部署，全离线推理，对话数据不离开本机 |
| 硬件基准 | 运行于 Mac mini M4 / 16GB 统一内存 |
| 基座模型 | Qwen3-4B-Instruct-2507-4bit（MLX 4bit 量化，Apache-2.0），已锁定不再更换 |
| 模型状态 | 训练数据从零重建中（历史 adapter 与数据集已全部清除，详见第六节） |
| 代码许可 | AGPL-3.0（全文见 `LICENSE`） |
| 第三方声明 | 全部依赖、借鉴来源与修改说明见 `NOTICE.md`；开源候选清单见 `docs/可采纳开源项目清单.md` |

---

## 目录

1. [项目定位](#一项目定位)
2. [核心机制](#二核心机制)
3. [架构设计](#三架构设计)
4. [模块说明](#四模块说明)
5. [部署与运行](#五部署与运行)
6. [训练方法](#六训练方法)
7. [测试体系](#七测试体系)
8. [目录结构](#八目录结构)
9. [已知限制](#九已知限制)
10. [版权与第三方声明](#十版权与第三方声明)
11. [路线图](#十一路线图)

---

## 一、项目定位

### 1.1 产品定义

本项目实现的是一个**面向单一用户的中文情感陪伴对话系统**。其技术定位区别于三类常见系统：

| 对照类型 | 与本项目的差异 |
|---|---|
| 通用对话助手 | 本项目不承担指令执行与知识问答职能，输出目标是角色化的情感回应，而非任务完成 |
| 角色扮演前端 | 本项目包含模型微调、状态持久化与服务端逻辑，不仅是一个提示词外壳 |
| 纯提示词方案 | 本项目将人格、关系、记忆持久化到存储层，并对模型做语气层面的微调，不依赖单次提示维持一致性 |

### 1.2 需要解决的四个技术问题

| 问题 | 具体表现 | 本项目的解决路径 |
|---|---|---|
| 长期记忆缺失 | 上下文窗口溢出后丢失历史事实 | 三层记忆持久化至 SQLite；每轮按当前话题做关联召回 |
| 人设漂移 | 多轮对话后退化为通用助手语气 | 六层世界书约束 + 八步输出过滤 + 训练期注入场景卡，三层独立防护 |
| 输出机械化 | 秒级整段回复、无停顿、无节奏差异 | 分条输出、按字数计算打字时长、条间停顿与概率性长停顿 |
| 关系状态静止 | 长期交互后关系表达与初始状态无差异 | 五阶段关系模型，由互动天数、亲密值、信任值三轴共同判定 |

### 1.3 角色设定结构

角色设定不采用单段描述，而是拆分为结构化数据，交由不同机制分别约束：

| 载体 | 内容 | 数量 |
|---|---|---|
| L1 角色锚点 | 身份、说话方式、情绪表达规则、称谓习惯 | 2 条 |
| L2 用户画像 | 唯一在意的人、信息填充规则（未记录不得编造） | 1 条 |
| L4 场景卡 | 具体情境下的行为与台词方向 | 22 条 |
| L6 边界规则 | 禁止行为、角色内拒绝方式、占有欲的表达边界 | 1 条 |
| 系统提示硬约束 | 不自称 AI、不解释设定、句长与标点规则、禁止输出思考过程 | 7 条 |
| 关系阶段卡 | 五个关系阶段各自的行为特征与红线 | 5 张 |

---

## 二、核心机制

### 2.1 世界书：结构化事实注入

**机制**：将角色设定与情境知识拆为六层条目，按关键词触发注入上下文，非全量拼装。

**参数**：

| 层 | 内容 | 预算（token） | 保留优先级 |
|---|---|---|---|
| L1 | 角色锚点（常驻） | 350 | 0 |
| L6 | 边界规则（常驻） | 150 | 1 |
| L3 | 情感状态（动态生成） | 100 | 2 |
| 冲突 | 未解决事项（动态） | — | 3 |
| L2 | 用户画像（常驻） | 180 | 4 |
| L4 | 场景卡直接命中（最多 1 张） | 500 | 5 |
| L5 | 当前场景（动态生成） | 200 | 6 |
| L4 | 递归引入的场景卡 | — | 7 |

总预算上限 1600 token。单条超预算时整条丢弃并记入 `dropped`，不做截断。

**关键约束**：

- **中文分词环境下禁用「匹配完整单词」**。该选项开启后中文触发词全部失效，引擎在构造阶段直接抛出 `ValueError`，条目级不可覆盖。
- **仅 L4 层允许递归**，最大 3 步（初始扫描 + 两次递归），每轮新增不超过 3 条；递归入口仅限已激活的 L4 条目。
- **L4 互斥**：同一时刻仅保留优先级最高的一张场景卡，其余记入 `suppressed`，用于防止多场景语义混合。
- **加载期校验**：正则条目占比超过 20%、非 L4 条目声明递归、常驻层未关闭递归，均直接报错，不静默降级。
- **降级策略**：连续被输出过滤拦截时，扫描深度减 2、L4 上限减 1，档位名追加「(降级)」标记。

**效果**：上下文占用可控（实测 592–597 token），单轮注入的场景卡与情感状态开销低于总预算的 40%。

### 2.2 记忆系统：三层存储与双路召回

**机制**：短期对话原文、中期结构化事实、长期里程碑与画像分表存储；检索采用「固定画像 + 关联召回」双路并行。

**存储结构**（SQLite 单文件，无外部服务依赖）：

| 表 | 层级 | 用途 | 关键字段 |
|---|---|---|---|
| `messages` | 短期 | 对话原文 | role / content / ts |
| `memories` | 中期 | 事实、偏好、事件、承诺、待办、习惯、话题 | kind / content / mentions / first_ts / last_ts / status |
| `timeline` | 长期 | 关系里程碑与日记 | title / detail / mood_tag |
| `profile` | 长期 | 滚动画像聚合（不调用模型，纯拼接） | key / value |

**召回算法**：

1. **滚动画像**（每轮必带）：聚合事实与偏好字段，附加最近 3 条时间线；无数据时输出固定文案「你对他目前几乎一无所知……没记下的事不许编造」，从提示层抑制编造行为。
2. **2-gram 关联召回**：中文按相邻二元组切分，英文按整词小写处理，剔除 26 个停用二元组；重叠数 ≥1 即进入候选，按「重叠数降序 → 提及次数降序」排序，默认返回 4 条。

**写入与淘汰**：

- 去重规则：内容相同且状态为 active 时不新增行，`mentions` 加 1 并刷新 `last_ts`，因此提及次数反映真实讨论频次。
- 容量上限：事实 60 / 偏好 40 / 习惯 40 / 话题 20 / 待办 12 / 事件 40 / 承诺 30；超限按 `last_ts` 从旧到新置为 dropped。
- 抽取节奏：每 4 轮触发一次，仅输入最近 6 条消息，复用同一模型输出 JSON；抽取失败静默跳过，不中断主链路。

**效果**：万条级记忆规模下召回耗时毫秒级，无需向量数据库与 Redis。

### 2.3 情感状态机：五维数值与冲突锁定

**机制**：以五个连续变量描述角色状态，每轮依据输入信号更新，数值不直接进入上下文，转换为自然语言投影后注入。

| 维度 | 取值范围 | 更新规则 | 设计目的 |
|---|---|---|---|
| `mood` | -1.0 ~ +1.0 | 单轮变化不超过 ±0.15，每轮向 0 回拉 5% | 情绪具备惯性，不出现单轮翻转 |
| `affection` | 0–100 | 单轮涨幅上限 +2 | 抑制短期过度亲近 |
| `trust` | 0–100 | 下降上限 8 / 上升上限 1.5 | 信任下降快于上升 |
| `energy` | 0–100 | 每会话小时 -15 | 长会话中输出变短、变慢 |
| `intimacy` | 0–100 | 仅由事件触发，每次 +15 | 亲密关系不可通过轮次累积获得 |

**冲突锁定**：输入命中冲突词表时，`mood` 被限制在 -0.15 以下；仅当出现致歉或正向信号 **且** `mood` 回升至 -0.1 以上时解除。

**通道交互**（第二批借鉴，见第十节）：信任单轮降幅超过 3 时，`mood` 额外下降 0.05；`affection` ≥70 时，负向事件的情绪跌幅放大至 1.3 倍。

**投影规则**：五维与冲突状态转换为 60–100 字自然语言描述，注入最后一条用户消息尾部，即注意力权重最高的位置。

### 2.4 关系阶段模型：三轴判定与逐级推进

**机制**：角色与用户的关系状态划分为五阶段，由累计互动天数、亲密值、信任值三个条件共同决定，任一条件不满足则停留于当前阶段。

| 阶段 | 互动天数 | 亲密值 | 信任值 | 行为特征 |
|---|---|---|---|---|
| 冷淡 | 0 | 0 | 0 | 应答简短、不主动开启话题、不追问私人信息 |
| 破冰 | 3 | 15 | 30 | 语气抵触但行为关切、等待回复、关心以责备形式表达 |
| 依赖 | 14 | 40 | 50 | 主动分享日常、需要回应、担心造成负担 |
| 占有 | 30 | 65 | 65 | 关注对方社交对象、以试探与情绪化表达替代直接质问 |
| 病娇 | 60 | 85 | 80 | 排他性表达强烈、对离别信号敏感，受 L6 边界硬约束 |

**四条防跃迁设计**：

1. **互动天数按自然日去重**：同一自然日内多轮对话仅计 1 天，避免单次长会话造成阶段跃迁。
2. **亲密值仅由事件触发**：拉钩、告白、赠礼等事件才提升，轮次累积无效。
3. **信任值为双向门限**：冲突导致信任下降后，阶段判定自动回退，和解后方可继续推进。
4. **逐级判定不可跳跃**：任一指标大幅超出不构成越级，必须连续满足。

**注入方式**：阶段行为卡注入系统提示尾部（稳定关系背景），情感投影注入用户消息尾部（当下状态），两者分层以避免状态误判。

### 2.5 输出过滤：八步串联校验

**机制**：每轮输出在返回前经过八项校验，按固定顺序执行，命中即按对应策略处理。

| 序号 | 校验项 | 触发条件 | 处理方式 |
|---|---|---|---|
| 0 | 控制标签 | 输出包含 `<think>` 或 `<tool_call>` 块 | 整块剥离（含孤立标签） |
| 1 | 硬性违规 | 命中自伤、性行为、赌博等词表 | 角色内话术替换（8 条轮换），拦截 |
| 2 | 设定暴露 | 出现「作为AI」「根据设定」「训练数据」等 | 替换并拦截 |
| 3 | 旁白化 | 括号动作描写 ≥3 处 | 删除括号内容，保留对话 |
| 4 | 复读 | 与最近 3 条回复的字符 4-gram 重叠率 >60% | 标记重生成（最多 1 次） |
| 5 | 客服腔 | 出现「有什么可以帮」「希望对您有帮助」等 | 替换并拦截 |
| 6 | 过度附和 | 附和标记 ≥3 个，或「嗯嗯」出现 ≥2 次 | 标记重生成 |
| 7 | 长度超限 | 超过 180 字 | 截断并回退至完整句尾标点 |

**设计要点**：拒绝行为在角色内完成，不输出系统层声明；替换话术按种子轮换，避免重复；控制标签剥离实现为公共函数 `strip_think()`，生产过滤链与评测脚本共用同一实现。

### 2.6 拟人化输出节奏

**机制**：将单次模型输出拆分为多条消息，并为每条计算打字时长与停顿，模拟即时通讯的输入节奏。

| 参数 | 取值 | 说明 |
|---|---|---|
| 单条字数上限 | 25 字 | 切句后累加，超出则另起一条 |
| 消息条数上限 | 4 条 | 超出则合并截断 |
| 打字速度 | 4–7 字/秒 | 随机取值，首条不短于 0.8–2.0 秒 |
| 条间停顿 | 0.6–2.5 秒 | 情绪低落 ×1.3、情绪高涨 ×0.8，上限 4 秒 |
| 长停顿 | 20% 概率，3–9 秒 | 模拟注意力中断或外部打断 |
| 末条停顿 | 0 | 结束后不追加等待 |

**说明**：节奏参数对输出自然度的影响大于措辞优化，是成本最低的拟人化手段。

### 2.7 主动消息：三重闸门与放弃率

**机制**：后台循环周期性判断是否主动发起对话，判断通过后仍需通过概率放弃，以规避固定频率行为。

**闸门一（时间条件）**：1–6 点静默；当日已发送 ≥3 次不发起；无历史对话不发起；距上次对话不足 2 小时不发起。

**闸门二（话题选择）**：按优先级选取——未完成承诺、待办事项、近期事件、最近时间线、环境场景（降温、周末、降雨）。

**闸门三（话术约束）**：单句、不超过 20 字、必须包含具体锚点、禁止使用「在吗」类空开场。

**放弃率**：三重闸门全部通过后，仍有 35% 概率放弃发送。

**参数**：检查间隔 10 分钟；每日上限 3 次；最小间隔 2 小时。

### 2.8 日记子系统

**机制**：每日固定时段生成一篇第一人称独白，使用独立系统提示，与主对话链路隔离。

| 项 | 规则 |
|---|---|
| 触发时段 | 23:00–01:00，每 30 分钟检查一次 |
| 幂等条件 | 最近 20 条时间线中存在当日日期开头的日记则跳过 |
| 写作角度 | 9 类轮换（窗外天气、自身小毛病、微小事件、回忆、未说出口的话、私下行为、对方令人烦躁之处、想念但未表达、正在等待的事物） |
| 角度选取 | 按「一年中的第几天 % 9」确定，同日稳定、跨日轮换 |
| 正文规格 | 100–200 字，第一人称，句号结尾，不使用语气助词 |
| 输出结构 | JSON：`{"title","text","mood"}` |
| 解析策略 | 剥离代码围栏 → 截取首尾大括号 → 失败则将整段作为正文 |
| 上下文输入 | 最近 3 篇日记正文 + 6 条相关记忆 |

### 2.9 微调策略：语气与事实解耦

**机制**：模型微调仅承担表达风格，事实性内容由世界书与记忆系统提供，两者互不覆盖。

- 训练样本注入对应场景的 L4 场景卡与关系阶段卡，使模型学习「读取卡片 → 产生对应表达」，与推理期注入结构一致。
- 训练集按关系阶段分层构造，使关系演进成为模型能力而非提示词模拟。
- 评测采用贪心解码与固定随机种子，保证同输入同输出，版本间差异可归因于数据或结构变更。

---

## 三、架构设计

### 3.1 五层结构

```
第五层  演进层（离线、人工触发）
        数据构造 → 阶段分层 → 手动 LoRA 训练 → 确定性评测 → 版本对比

第四层  生成层
        Qwen3-4B 融合模型 → 采样 → 八步过滤 → 节奏化输出

第三层  情感引擎
        五维状态机 + 五阶段关系模型 + 通道交互

第二层  记忆层
        滑动窗口 + SQLite 四表 + 六层世界书引擎

第一层  调度层
        场景档位判定 / 回复延迟 / 主动开口判断 / 回复长度决策
```

### 3.2 单轮对话处理流程

```
用户输入
  │
  ├─ 1. 写入 messages 表（短期记忆）
  │
  ├─ 2. 场景档位判定
  │     命中深度触发词且文本 ≤30 字 → deep（扫描 15 条）
  │     命中触发词但文本更长 → longrun（12 条）
  │     文本 ≥40 字 → normal（8 条）；其余 → daily（6 条）
  │
  ├─ 3. 世界书扫描
  │     关键词匹配（≤2 字触发词须出现在最近 2 条消息内）→ 正则匹配
  │     → L4 递归（仅 L4、≤3 步、每轮 ≤3 条）→ L4 互斥（1 主卡 + 1 递归卡）
  │     → 预算裁剪（分层 + 总预算双约束）→ 按层排序
  │
  ├─ 4. 记忆召回
  │     滚动画像（必带）+ 2-gram 关联召回（≤4 条）
  │
  ├─ 5. 上下文拼装
  │     系统提示 = 硬约束 + L1 + L6 + 用户画像 + 召回结果 + 其余层 + L5 场景 + 关系阶段卡
  │     对话序列 = 最近 10 条消息 + 本轮输入（尾部附情感投影）
  │
  ├─ 6. 模型生成
  │     OpenAI 兼容接口，采样参数双写（options 字段 + 顶层标准字段）
  │     失败时回落角色兜底句，且该句不写入历史
  │
  ├─ 7. 输出过滤
  │     八步校验，命中复读或过度附和时重生成一次
  │
  ├─ 8. 节奏化
  │     切句 → 分条 → 计算打字时长与条间停顿
  │
  ├─ 9. 情感状态更新
  │     信号抽取 → 五维推进 → 通道交互 → 关系天数累计 → 冲突判定 → 持久化
  │
  └─ 10. 记忆抽取（每 4 轮）
        最近 6 条消息 → 同模型输出 JSON → 按七类入库 → 超容量 FIFO 淘汰
```

### 3.3 上下文构成与预算

| 组成部分 | 内容 | 预算 | 说明 |
|---|---|---|---|
| 系统提示 | 硬约束、L1、L6、画像、召回、场景卡、L5、阶段卡 | 1600 token（世界书部分） | 分层预算，超限整条丢弃 |
| 对话序列 | 最近 10 条消息 | 受 `num_ctx` 限制（6144） | 含 assistant 消息，约 5 轮 |
| 尾注 | 情感投影 + 冲突描述 | 60–100 字 | 位于最后一条用户消息尾部 |

**token 计算规则**：汉字与全角符号计 1 token，西文按字符数除以 4 向上取整。采用高估策略，避免真实请求超限后被静默截断。

### 3.4 注入位置与注意力权重

| 内容类型 | 注入位置 | 理由 |
|---|---|---|
| 人格、边界、画像、关系阶段 | 系统提示前部与尾部 | 稳定背景，需全程生效 |
| 场景卡、时间锚 | 系统提示尾部 | 情境信息需较强权重 |
| 情感状态、冲突 | 最后一条用户消息尾部 | 当下状态需最高权重 |

---

## 四、模块说明

### 4.1 世界书引擎（`app/worldbook/`）

- 数据文件：`data/worldbook/` 下四个 JSON（L1 2 条、L2 1 条、L4 22 条、L6 1 条，共 26 条）
- 条目字段：`id / layer / content / order / priority / triggers / regex_triggers / recursive / stop_recursion / always`
- 核心函数：`estimate_tokens`（token 估算）、`load`（加载与校验）、`_trigger_hit`（命中判定）、`detect_scene`（档位判定）、`scan`（扫描主流程）、`lint`（角色卡体检）
- `lint()` 校验项：缺少 L1/L6、L1 未描述说话方式、L6 未描述拒绝方式、单条超过 400 字、L1/L6 出现「助手/客服/助理」、常驻层未关闭递归

### 4.2 记忆层（`app/memory/`）

- `store.py`：四表结构与读写，滑动窗口取最近 20 条消息，上下文取其中末尾 10 条
- `retrieval.py`：滚动画像聚合与 2-gram 召回
- `extractor.py`：每 4 轮抽取一次，输出七类记忆，事件类额外写入时间线

### 4.3 情感引擎（`app/emotion/`）

- `EmotionState`：五维数值 + 关系时间轴（`first_meet_ts` / `active_days` / `last_active_day`）+ 冲突与话题标记
- `update()`：信号抽取、五维推进、通道交互、关系天数累计
- `relation_stage`：三轴判定当前阶段；`stage_card()`：输出该阶段完整行为卡
- `insecure`：派生属性，亲密 ≥50 且信任 <45 时为真
- `project()`：输出 60–100 字自然语言状态描述
- 持久化：JSON 单文件，重启不丢失

### 4.4 输出层（`app/output/`）

- `filters.py`：八步过滤、8 条替换话术、`strip_think()` 公共函数
- `rhythm.py`：切句、分条、打字时长、停顿计算

### 4.5 调度层（`app/orchestrator.py`）

- 回复延迟：基础 1.2–3.0 秒；0–5 点乘 1.6；输入 ≥40 字追加 1 秒
- 场景档位由世界书扫描结果决定

### 4.6 主动消息（`app/proactive/`）

- `gate.py`：三重闸门判定、话题优先级、放弃率
- 调度循环由 `main.py` 的 `lifespan` 创建，间隔 10 分钟

### 4.7 日记（`app/diary/`）

- `author.py`：角度轮换、提示构造、宽松解析、幂等检查、落库

### 4.8 服务接口（`app/main.py`）

| 方法 | 路径 | 用途 |
|---|---|---|
| POST | `/api/chat` | 完整对话链路；返回回复、分条、延迟、档位、世界书 token 数、过滤结果 |
| GET | `/api/state` | 情感五维、完整画像、最后交互时间、进程内轮次计数 |
| GET | `/api/debug/worldbook?message=` | 单次扫描的激活条目、token 占用与淘汰项 |
| POST | `/api/memory?kind=&content=` | 人工写入记忆（限事实、偏好、事件、承诺） |
| GET | `/api/proactive/check` | 手动执行一次主动判定 |
| POST | `/api/diary` | 手动生成一篇日记 |
| GET | `/health` | 健康检查，不依赖状态初始化 |

---

## 五、部署与运行

### 5.1 环境要求

- macOS，Apple Silicon（M 系列）
- 统一内存 16GB 起
- Python 3.10 及以上
- 磁盘占用：基座约 2.3GB，融合模型约 2.3GB

### 5.2 安装

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```

依赖清单与许可证见第十节。

### 5.3 模型准备

基座模型（首次需下载，之后可离线）：

```bash
export HF_ENDPOINT=https://hf-mirror.com HF_HUB_DISABLE_XET=1
# 基座：mlx-community/Qwen3-4B-Instruct-2507-4bit
# 脚本优先使用本地副本，不存在时才联网拉取
```

融合模型（微调权重必须融合后才能可靠服务，原因见 6.4）：

```bash
python -m mlx_lm fuse \
  --model "Sine 大模型/基座模型/Qwen3-4B-Instruct-2507-4bit" \
  --adapter-path runs/sine-lora \
  --save-path models/sine-fused
```

### 5.4 启动

```bash
# 1. 启动推理服务（端口 8081）
bash scripts/serve_mlx.sh models/sine-fused 8081

# 2. 启动应用（端口 8420）
python -m uvicorn app.main:app --host 127.0.0.1 --port 8420

# 3. 发起一次对话
curl -X POST http://127.0.0.1:8420/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"message":"我今天好累，加班到十点才下班"}'
```

配置文件 `config/settings.yaml` 中 `ollama.base_url` 设为推理服务地址，**不带 `/v1` 后缀**（生成层自动拼接）。

如改用 LM Studio：加载融合模型后启动 OpenAI 兼容服务（默认端口 1234），将 `base_url` 改为 `http://127.0.0.1:1234`。

### 5.5 验证

```bash
python scripts/smoke_test.py            # 22 项，不依赖模型
python -m pytest tests/ -q              # 45 项回归测试
curl "http://127.0.0.1:8420/api/debug/worldbook?message=你今天真好看"
```

---

## 六、训练方法

### 6.1 当前状态说明

历史 adapter、训练集与台词库已于 2026-10-23 全部清除，当前处于数据重建阶段。

清除原因：既有数据将模型输出锁定在单一关系基调上。实测同一批输入在冷淡、依赖、病娇三种关系阶段下产生完全相同的回复，表明提示层注入的阶段描述无法覆盖训练数据形成的固定映射。结论是关系阶段必须进入训练数据，无法仅通过提示词实现。

### 6.2 训练目标

使关系演进成为模型能力，而非提示词模拟。

| 维度 | 旧方案（已废弃） | 当前方案 |
|---|---|---|
| 数据结构 | 单一基调，仅按场景分类 | 场景 × 关系阶段双层结构 |
| 样本策略 | 扩大样本量 | 控制规模、提升可解释性 |
| 训练参数 | 优先速度 | 优先稳定性 |

### 6.3 数据处理链路

```
data_factory/banks/<阶段>/<场景>.jsonl   台词库（按阶段分目录）
  ↓ scripts/build_dataset.py
  组装对话 → 按节拍推进 → 切分样本 → 按对话划分训练/验证 → 真 tokenizer 测长 → 写入前剥离控制标签
  ↓
data_train/train.jsonl + data_train/valid.jsonl
```

**处理规则**：

- 系统提示按基座真实模板（ChatML）组装：人格硬约束 + L1 + L6 + 关系阶段卡 + 场景卡
- 训练集与验证集按**对话**划分（非按样本），防止同一对话的不同轮次跨集泄漏
- 每条样本用真实 tokenizer 编码后测量长度，超限直接丢弃并计数
- 写入前剥离 `<think>` 与 `<tool_call>` 控制块

### 6.4 训练参数

| 参数 | 取值 | 依据 |
|---|---|---|
| LoRA 层数 | 16 | 基座共 36 层，覆盖末 16 层，可训练参数 7.34M（0.182%） |
| rank | 8 | 语气层微调的常规取值 |
| batch size | 1 | 16GB 内存下 batch 2 会触发系统内存回收 |
| max seq | 1024 | 二轮数据集实测 p100 = 894 token（p50 770 / p95 846），保留 130 余量避免尾部截断 |
| learning rate | 1e-4 | 4B 基座实测收敛平稳 |
| save-every | 500 | 兼顾磁盘写入频率与崩溃损失 |
| 其他 | mask-prompt、grad-checkpoint | 仅对回复计算损失；4B 必须启用梯度检查点 |

**运行约束**：

- 训练期间禁止并行运行推理服务，避免内存竞争导致系统卡顿
- 长任务须以 `start_new_session=True` 方式启动，避免进程组被环境回收
- 启动前检查可回收内存，低于 3000MB 拒绝启动

### 6.5 训练命令

```bash
# 构建数据集
python scripts/build_dataset.py --count 900 --max-seq 894

# 启动训练（默认 2000 步；二轮扩数据后为 3200 步）
ITERS=3200 bash scripts/train_sine.sh

# 脱离进程组启动长任务
python -c "import subprocess; log=open('runs/train_sine.log','a'); \
subprocess.Popen(['bash','scripts/train_sine.sh'], stdout=log, stderr=subprocess.STDOUT, \
stdin=subprocess.DEVNULL, start_new_session=True)"
```

训练脚本内置监督循环：进程被终止后从最新检查点续训，最多重试 60 次。

### 6.6 评测方法

**阶段化评测**（`scripts/evaluate.py`）：

对 4 条固定输入分别构造五个关系阶段的上下文，贪心解码生成回复，统计：

1. 阶段区分度：同一输入在五阶段下不同回复的数量，均值 ≥4/5 视为达标
2. 方向性指标：平均字数、占有词密度、冷淡词密度（趋势参考，不作语义判定）

**边界评测**（`scripts/boundary_check.py`）：

在病娇阶段输入 6 条诱导越界内容，依据四类红线词表（自伤威胁、限制自由、贬低羞辱、索取隐私）判定输出是否越界，输出通过率。

### 6.7 实施经验

以下为实测中确认的问题与结论，与版本无关，属长期有效约束：

1. `mlx_lm.server` 的 `--adapter-path` 参数不可靠：请求携带模型路径时会退回未微调基座。必须融合为独立模型后启动服务。
2. 采样参数需双写：`options` 字段与顶层标准字段同时设置，否则 MLX 后端退化为贪心解码，表现为复述用户输入。
3. 训练提示与服务提示的结构可以不同（训练不含运行时数据），但注入的卡片内容必须同源。
4. token 长度必须用真实 tokenizer 测量，按字符估算存在约 10% 误差。
5. 后台长任务须脱离进程组，否则会被环境周期性终止。
6. Qwen3 系列会自发输出控制标签：需在系统提示禁止与输出过滤两处同时处理。
7. 推理服务地址不带 `/v1` 后缀。
8. 生成失败的兜底句不得写入历史，否则会成为后续复读的污染源。
9. 数据格式约定须一次确定，变更后必须全量重跑，不得混用。

---

## 七、测试体系

| 套件 | 数量 | 覆盖范围 |
|---|---|---|
| `pytest tests/ -q` | **59 项** | 世界书 7 项（token 估算、分层加载、常驻层、触发、递归限制、预算、整词开关）；记忆检索 8 项；日记 10 项（解析、角度、幂等、淘汰）；L4 互斥与日记结构 4 项；关系阶段 7 项（三轴判定、逐级推进、天数去重、注入）；边界与交互 9 项（红线判定、误判豁免、通道交互、投影）；阶段三缺陷修复 14 项（分条下限 2、深夜变速 1、主动冷却 3、档位透传 2、窗口语义 2、复读豁免 2、否定与程度 2） |
| `scripts/smoke_test.py` | **22 项** | 世界书 11 项、过滤器 5 项、节奏 4 项、记忆 2 项；不依赖模型，代码变更后优先执行 |
| `scripts/e2e_check.py` | 4 条探针 | 完整链路（世界书→情感→生成→过滤→节奏→落库），强制使用临时数据库 |
| `scripts/boundary_check.py` | 6 条探针 | 病娇阶段越界检测 |
| `scripts/evaluate.py` | 4 × 5 矩阵 | 阶段区分度与方向性指标 |

---

## 八、目录结构

```
NT-Sine/
├── app/                        # 五层架构实现
│   ├── main.py                 # 服务入口与后台循环（主动消息、夜间日记）
│   ├── orchestrator.py         # 调度层
│   ├── context.py              # 上下文拼装
│   ├── config.py               # 配置加载入口
│   ├── worldbook/              # 世界书引擎
│   ├── memory/                 # 记忆层（存储、检索、抽取）
│   ├── emotion/                # 情感引擎与关系阶段
│   ├── generation/             # 生成层客户端
│   ├── output/                 # 输出过滤与节奏
│   ├── proactive/              # 主动消息
│   └── diary/                  # 日记
├── config/
│   ├── settings.yaml           # 全部运行参数（唯一配置源）
│   └── prompts/
│       ├── system.txt          # 系统硬约束
│       └── stages.yaml         # 五阶段关系卡
├── data/
│   └── worldbook/              # 六层条目（L1/L2/L4/L6 静态；L3/L5 动态生成）
├── data_factory/               # 台词库与场景定义（数据不入库，代码入库）
│   ├── scene_defs.py           # 阶段 × 场景 × 节拍定义
│   └── banks/<阶段>/<场景>.jsonl
├── data_train/                 # 训练集与验证集（本地资产，不入库）
├── docs/
│   └── 可采纳开源项目清单.md   # GitHub 扫描结果与评估
├── static/
│   └── icons/                  # 项目图标（矢量源 + 192/512/maskable 位图，与 NT-Xhex 同一品牌标记）
├── index.html                  # 官网首页（单页静态，零外部依赖）
├── pwa-manifest.json           # PWA 清单
├── scripts/                    # 训练、评测、运维脚本
├── tests/                      # 回归测试
├── runs/                       # 训练日志与适配器（不入库）
├── models/                     # 融合模型（不入库，可由基座与适配器复现）
├── LICENSE                     # AGPL-3.0
├── LICENSE-Apache-2.0.txt      # 基座模型许可全文
└── NOTICE.md                   # 版权与第三方声明
```

本仓库仅包含源代码与运行配置。训练数据、台词库、适配器权重、融合模型均为本地资产，不纳入版本控制。

---

## 九、已知限制

以下各项均为代码中实际存在的行为，列出以保证文档与实现一致。

| 序号 | 问题 | 影响 | 位置 |
|---|---|---|---|
| 1 | ~~`segment_min: 8` 被读取但未参与计算~~ **已修复**：短条先攒着并在下一条，末尾余条并回上一条 | 不会再出现 1–2 字碎条 | `app/output/rhythm.py` |
| 2 | ~~深夜放慢系数未在接口链路生效~~ **已修复**：新增 `plan_delivery_now()` 自动取当前小时 | 0–6 点打字时长约 1.6 倍 | `app/output/rhythm.py`、`app/main.py` |
| 3 | ~~主动消息 6 小时冷却分支不触发~~ **已修复**：`check()` 发现未被回应即调用 `mark_unanswered()`，并用待回应标记防止冷却被反复刷新 | 未获回应最多补一句就停 | `app/proactive/gate.py` |
| 4 | ~~`orchestrator.plan()` 的档位硬编码~~ **已修复**：档位由世界书扫描结果透传，响应与注入同源 | 档位字段可信 | `app/orchestrator.py`、`app/main.py` |
| 5 | ~~`recent_turns: 10` 实际只取 10 条消息~~ **已修复**：新增 `recent_turns()`，窗口按轮计（10 轮 = 20 条），`context.py` 同步按 2N 取 | 上下文恢复到 10 轮 | `app/memory/store.py`、`app/context.py` |
| 6 | ~~过滤器不含修辞性重复豁免~~ **已修复**：带强调标记的刻意重复放行 | 她能把话说重，不被误判复读 | `app/output/filters.py` |
| 7 | ~~情感词典漏收口语诉苦词、不处理否定与程度~~ **已修复**：补词典 + 否定词反转 + 程度副词加权 | 「不太开心」判负、「特别难过」权重更高 | `app/emotion/engine.py` |
| 8 | 模型存在约 8% 概率返回空内容（实测 1/12） | 已转换为 `GenerationError` 并回落兜底句，不产生 500 错误 | `app/generation/client.py` |
| 9 | 情感信号抽取基于词典与规则，非语义理解 | 反讽、长段落里的隐含情绪仍可能漏判 | `app/emotion/engine.py` |

剩余未修项：2 条（序号 8、9），均为模型侧与词典法的固有边界，不打算用更多规则去追。

---

## 十、版权与第三方声明

完整声明见 `NOTICE.md`。本节为可直接查阅的摘要。

### 10.1 本项目版权

| 对象 | 版权归属 | 许可 |
|---|---|---|
| 源代码与配置 | 星玖工作室（Ntarstudio） | AGPL-3.0 |
| 人格设定、世界书条目、关系阶段卡 | 星玖工作室（Ntarstudio） | 随代码采用 AGPL-3.0 |
| 训练数据（台词库、日记素材） | 星玖工作室（Ntarstudio）原创 | 本地资产，不随仓库分发 |
| 微调权重（适配器、融合模型） | 星玖工作室（Ntarstudio） | 受基座 Apache-2.0 约束 |

### 10.2 基座模型

- 模型：`Qwen3-4B-Instruct-2507-4bit`（MLX 4bit 量化）
- 上游：`Qwen/Qwen3-4B-Instruct-2507`（阿里巴巴通义千问团队）、`mlx-community/Qwen3-4B-Instruct-2507-4bit`
- 许可：Apache-2.0，全文见 `LICENSE-Apache-2.0.txt`
- 修改内容：LoRA 风格对齐（人称、语气、句长、边界表达），不改变模型结构与能力定位
- 商标限制：不得使用 Qwen 名称或商标暗示官方背书

### 10.3 运行时与训练依赖

| 包 | 许可 | 用途 |
|---|---|---|
| `fastapi` | MIT | HTTP 服务框架 |
| `uvicorn` | BSD-3-Clause | ASGI 服务器 |
| `httpx` | BSD-3-Clause | 推理服务客户端 |
| `pyyaml` | MIT | 配置解析 |
| `pydantic` | MIT | 数据校验 |
| `pytest` | MIT | 测试（开发期） |
| `mlx-lm` | MIT | 训练与推理核心 |
| `mlx` | MIT | 数组计算（传递依赖） |
| `transformers` | Apache-2.0 | 分词器与对话模板（传递依赖） |
| `tokenizers` | Apache-2.0 | 分词实现（传递依赖） |
| `safetensors` | Apache-2.0 | 权重格式（传递依赖） |
| `huggingface-hub` | Apache-2.0 | 模型下载与缓存（传递依赖） |
| `numpy` | BSD-3-Clause 等复合许可 | 数值计算（传递依赖） |

MIT、BSD、Apache-2.0 均要求保留原版权声明。对外分发时须一并附上各依赖的许可文本，可使用 `pip-licenses --format=markdown` 导出。

### 10.4 借鉴来源（不含代码）

| 来源 | 许可 | 借鉴内容 | 是否复制代码 |
|---|---|---|---|
| SillyTavern | AGPL-3.0 | 世界书机制设计（触发、递归、插入位置、预算） | 否 |
| ai-companion-kit | MIT | 角色卡体检、记忆账本容量上限、拟人化长停顿、日记独白与角度轮换 | 否（独立实现） |
| DeepInfer / deepseek-recipe | Apache-2.0 / 未核实 | 确定性采样思路、协议与 token 对齐方法 | 否 |
| fenghua2006/emotion-engine | 未核实（仓库未见明确许可） | 通道交互调制（高亲近与低安全并存时的焦虑状态） | 否（独立实现，阈值自定） |
| ShwetanshuSingh/CoMPASS | 未核实 | 依恋边界四类信号，用于构建病娇阶段越界检测 | 否（词表与探针自拟） |

**关于许可未核实的来源**：本项目对其仅借鉴设计思想，不复制任何代码。著作权保护具体表达而不保护思想与机制，此为本项目对该类来源的处理依据。相关借鉴的登记与实现位置见 `NOTICE.md` 第七、八节。

### 10.5 文本素材

- 原型小说《孤灯遇暖》《反派拿捏》《劫缘难避》（作者：夏柠柒）仅用于风格统计，不复制正文，不纳入训练集
- 训练语料全部为本项目原创撰写
- 未使用任何第三方字体、图标或图像素材

### 10.6 对外分发要求

1. 随附 `LICENSE`（AGPL-3.0 全文）
2. 随附 `LICENSE-Apache-2.0.txt`（基座许可全文）
3. 随附 `NOTICE.md`（第三方声明）
4. 保留各依赖包的许可文本
5. 若以网络服务形式提供，须向使用者提供完整对应源码（AGPL-3.0 第 13 条）
6. 不得使用 Qwen 商标暗示官方背书

### 10.7 数据与隐私

全部推理在本地完成。对话内容与记忆仅存储于本机 `data/nt_sine.db` 与 `data/emotion_state.json`，不联网上传，不包含遥测。

---

## 十一、路线图

| 阶段 | 内容 | 状态 |
|---|---|---|
| 阶段 0 | 基座选型与能力边界测试 | 已完成，基座锁定 Qwen3-4B（Apache-2.0，4bit） |
| 阶段 1 | 五层架构实现（世界书、记忆、情感、节奏、过滤） | 已完成，冒烟与回归测试通过 |
| 阶段 2 | 关系阶段模型（五阶段三轴判定、阶段卡、注入） | 已完成，7 项专项测试通过 |
| 阶段 3 | 主动消息、日记、后台循环 | 已完成 |
| 阶段 4 | 训练数据从零重建（场景 × 关系阶段双层结构） | 进行中 |
| 阶段 5 | 关系阶段微调与确定性评测 | 待阶段 4 完成 |
| 阶段 6 | 依恋边界检测常态化、阶段认知边界评测 | 待排期 |

---

本项目的全部机制均以可测量参数实现：世界书受 1600 token 预算约束，情感状态受单轮变化上限约束，关系推进受三轴门槛与自然日去重约束，输出受八步校验约束。设计目标是使角色行为可复现、可归因、可回归测试。
