159 lines
8.3 KiB
Markdown
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`(播放旁白)。
|