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

8.3 KiB

聊天系统与存档架构技术文档

本文档详细说明了 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 代码示例

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 接口

所有需要存档的系统(如背包、任务、玩家状态)都必须实现此接口。

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. 注册:
    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
  • 过滤规则: 仅实例化 roleuserassistant 的消息,过滤掉系统提示词(system)。
  • 性能考量: 相比于 TabletController 主聊天窗口保留最近 50 条消息的滚动删除机制,History Panel 会全量渲染所有历史记录(每次打开时清理旧 UI 节点并重新生成),适合供玩家通过专门的历史界面进行完整回顾。

4.2.1 聊天气泡换行与最大宽度

聊天窗口(AssistPanel)与历史窗口(HistoryPanel)共用同一个气泡预设体 MessageRow V2,气泡的自动换行行为由预设体与 MessageAligner 共同决定。

  • 预设体关键点:
    • 文本组件为 TextMeshProUGUI,必须开启 Word Wrapping。
    • 气泡背景(Bubble)通过布局组件自适应内容尺寸。
  • 动态限宽:
    • MessageAligner 会优先按父级 ScrollRectViewport 实际宽度计算“有效最大气泡宽度”,并使用 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。 格式示例:

{
    "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(播放旁白)。