Files
Echo/Assets/_Project/Docs/ChatSystem_Technical_Reference.md
2026-07-08 20:42:51 +08:00

159 lines
8.3 KiB
Markdown

# 聊天系统与存档架构技术文档
本文档详细说明了 LLM 聊天系统的双层数据架构(滚动压缩 + 全局存档)以及通用的存档系统实现。
## 1. 核心架构概述
为了解决 LLM API Token 限制与游戏数据持久化之间的矛盾,本项目采用了 **“双层数据分离”** 策略:
| 特性 | API 上下文 (Context Window) | 全局存档 (Global Chat Log) |
| :--- | :--- | :--- |
| **位置** | `DeepSeekService` / `DoubaoService` | `LLMChatManager` |
| **生命周期** | 临时,随对话进行滚动修剪 | 永久,贯穿游戏存档 |
| **用途** | 维持 API 短期记忆,控制成本 | 历史回看、剧情记录 |
| **数据结构** | `List<Message>` (限制长度) | `List<Message>` (无限增长) |
---
## 2. 滚动压缩 (Rolling Compression)
### 2.1 原理
为了防止对话历史无限增长导致 API 请求失败或费用激增,服务层实现了自动修剪逻辑。
### 2.2 实现细节
* **脚本**: `DeepSeekService.cs`, `DoubaoService.cs`
* **参数**: `maxContextWindow` (默认 20 条)
* **逻辑 (`TrimHistory`)**:
1. **保护 System Prompt**: 列表索引 `0` 的消息(系统提示词)永远不会被删除。
2. **移除旧消息**: 当 `messageHistory.Count > maxContextWindow` 时,从索引 `1` 开始移除最早的消息,直到数量满足限制。
### 2.3 代码示例
```csharp
private void TrimHistory()
{
// 始终保留 index 0 (System Prompt)
if (messageHistory.Count > maxContextWindow)
{
// 从 index 1 开始移除多余的消息
int removeCount = messageHistory.Count - maxContextWindow;
if (removeCount > 0)
{
messageHistory.RemoveRange(1, removeCount);
}
}
}
```
---
## 3. 通用存档系统 (Core.SaveSystem)
本项目实现了一套基于接口的、高内聚低耦合的存档系统。
### 3.1 核心组件
#### `ISaveable` 接口
所有需要存档的系统(如背包、任务、玩家状态)都必须实现此接口。
```csharp
public interface ISaveable
{
string GetSaveID(); // 返回唯一标识符 (例如 "Inventory")
string CaptureState(); // 返回序列化后的 JSON 字符串
void RestoreState(string json); // 根据 JSON 恢复状态
}
```
#### `SaveManager` 管理器
* **单例模式**: `SaveManager.Instance`
* **职责**:
* 维护 `saveables` 列表。
* 处理文件读写 (`savegame.json`)。
* **晚注册支持**: 维护 `loadedDataCache`,即使某个模块在游戏运行后期才加载(如动态生成的 UI),在注册时也能立即获取到它的存档数据。
### 3.2 扩展指南:如何添加新存档模块
假设你要为“背包系统”添加存档功能:
1. **实现接口**: 让 `InventoryManager` 实现 `ISaveable`
2. **定义数据**: 创建一个私有类 `InventoryData` 用于序列化。
3. **注册**:
```csharp
void Start() {
SaveManager.Instance.Register(this);
}
void OnDestroy() {
SaveManager.Instance.Unregister(this);
}
```
4. **实现方法**:
* `GetSaveID()` -> 返回 "InventorySystem"。
* `CaptureState()` -> `JsonUtility.ToJson(myData)`.
* `RestoreState(json)` -> `JsonUtility.FromJson<InventoryData>(json)`.
---
## 4. 聊天记录的持久化流程
`LLMChatManager` 实现了 `ISaveable` 接口,负责将 `fullChatLog` 写入磁盘。
### 4.1 数据流向
1. **用户发送**:
* UI 调用 `LLMChatManager.SendUserMessage`。
* 消息被添加到 `fullChatLog` -> **立即触发 SaveGame**。
* 消息被转发给 `DeepSeekService` -> 添加到临时列表 -> **触发滚动压缩** -> 发送 API。
2. **AI 回复**:
* API 回调返回文本。
* 消息被添加到 `fullChatLog` -> **立即触发 SaveGame**。
* 消息被添加到临时列表 -> **触发滚动压缩**。
* UI 更新显示。
### 4.2 UI 层面的历史记录呈现
新增了 `ChatHistoryPanelController.cs` 专门用于呈现完整的对话历史。
* **触发机制**: 当包含该脚本的 UI 面板(如 Reach UI 的 History Panel)被激活(`OnEnable`)时,会自动调用 `RefreshHistoryUI()`。
* **数据来源**: 直接读取 `LLMChatManager.fullChatLog`。
* **过滤规则**: 仅实例化 `role` 为 `user` 或 `assistant` 的消息,过滤掉系统提示词(`system`)。
* **性能考量**: 相比于 `TabletController` 主聊天窗口保留最近 50 条消息的滚动删除机制,History Panel 会全量渲染所有历史记录(每次打开时清理旧 UI 节点并重新生成),适合供玩家通过专门的历史界面进行完整回顾。
### 4.2.1 聊天气泡换行与最大宽度
聊天窗口(AssistPanel)与历史窗口(HistoryPanel)共用同一个气泡预设体 `MessageRow V2`,气泡的自动换行行为由预设体与 `MessageAligner` 共同决定。
* **预设体关键点**:
* 文本组件为 `TextMeshProUGUI`,必须开启 Word Wrapping。
* 气泡背景(Bubble)通过布局组件自适应内容尺寸。
* **动态限宽**:
* `MessageAligner` 会优先按父级 `ScrollRect` 的 `Viewport` 实际宽度计算“有效最大气泡宽度”,并使用 `viewportWidthRatio` 进行比例限制。
* `maxBubbleWidth` 作为硬上限,用于避免在超宽 UI 下出现过长单行。
* **排查清单(不换行/溢出屏幕)**:
* `ScrollRect.m_Viewport` 必须正确指向对应的 Viewport(否则无法得到准确可用宽度)。
* `MessageAligner.bubbleBackground` 必须绑定到 Bubble 的 `Image`,否则无法定位到正确的文本组件进行限宽。
* `MessageAligner.clampToViewportWidth` 需保持启用;如需要更早换行,调小 `viewportWidthRatio`(例如 0.6)。
* 气泡预设体的根节点与 Bubble/Text 的 `RectTransform` 不应保留非零 `AnchoredPosition` 或固定宽度,否则会干扰布局系统对宽度与换行的接管。
### 4.3 存档文件
存档位于 `Application.persistentDataPath/savegame.json`。
格式示例:
```json
{
"keys": ["ChatSystem"],
"values": ["{\"logs\":[{\"role\":\"user\",\"content\":\"你好\"},...]}"]
}
```
### 4.4 预置 Facts (Preset Facts)
为了在“新游戏无存档”的情况下让 AI 一开始就具备关键世界事实(例如钥匙位置),项目支持从 StreamingAssets 读取预置 Facts,并写入 `ContextMemoryManager.facts`,随后注入到 L1 的 System Prompt。
* **预置文件路径**: `Assets/StreamingAssets/LLM/preset_facts.json`
* **加载时机**: `PresetFactsInstaller` 在检测到 `savegame.json` 不存在时才会加载预置 Facts,并且不会主动写入 `savegame.json`,以避免影响“新游戏判定/流程”。若 `savegame.json` 存在但缺少 `ContextMemory` 条目,也会自动补齐预置 Facts。
* **落盘时机**: 当首次触发 `SaveManager.SaveGame()`(例如玩家首次聊天)时,Facts 才会随其它系统一起写入 `savegame.json`。
### 4.5 通过 UnityEvent 动态注入 Facts
项目提供 `FactsSequence` 工作流,允许在交互/任务等 UnityEvent 触发时,把一组 FactOperation 写入 `ContextMemoryManager.facts`。
* **配置方式**: 在场景中创建一个父物体挂 `FactsSequence`,子物体挂 `FactOperationBehaviour` 配置 type/key/value/source。
* **触发方式**: 在任意 UnityEvent 中直接绑定 `FactsSequence.Apply()`。
* **写入策略**: 对 Facts 做 ADD/UPDATE/DELETE 后,会更新 system prompt,并可选立即存档。
### 4.6 Function Calling(严格 JSON + 调试密钥门槛)
项目支持 AI 在回复末尾输出严格 JSON 指令,并由运行时解析执行(例如开门/解锁)。
* **JSON 协议**: `{"text":"给玩家看的文本","commands":[{"type":"UnlockState","targetId":"door_main_lock","value":true}]}`
* **安全门槛**: 可选启用“调试密钥门槛”(形如 `[2310487514]`),用于 Debug 阶段方便测试;正式流程可关闭该门槛并改用剧情状态 Facts 作为执行条件(例如 `状态-ECHO7-已被识破=是` 后才允许执行关键命令)。
* **显示规则**: UI 与 `fullChatLog` 只记录 `text`(玩家可见文本),不会把命令 JSON 显示/存入历史。
* **扩展指令**: 已支持 `Task`(任务状态)、`SetActive`(激活/隐藏物体)、`Narration`(播放旁白)。