从聊天插件到叙事系统

大语言模型放进游戏之后,最先显现的是开放式表达能力:玩家可以自由追问、质疑、试探角色,而不是只能点击固定选项。但互动叙事游戏的脆弱问题也随之出现。角色说错一句话,可能会让玩家误判线索;模型口头答应“门开了”,也不等于场景中的门锁真的变化。

因此,《回声》把 LLM 定位为叙事表达与策略生成的一部分,而不是世界状态的控制中心。游戏系统保存事实,游戏系统裁决动作,模型只在边界内生成文本和提出结构化请求。

一句话概括:开放的是玩家输入和角色回应,收紧的是状态来源和行为执行。

三层职责

L1 表达

提示词与叙事约束层负责 ECHO-7 的身份、语气、阶段性隐瞒、拒答边界与输出格式。

L2 状态

可验证记忆层用 Facts 维护门禁、线索、世界状态和任务阶段,为模型提供稳定锚点。

L3 执行

工具调用执行层把行为请求转成 JSON 命令,经白名单与权限检查后路由到 Unity 组件。

这三个层级分别对应“说什么、依据什么说、能不能真的做”。如果只依赖提示词,模型仍可能在长对话中漂移;如果只加入 Facts,模型可以更准确地回答,但仍不能安全执行副作用;完整三层则能把表达、状态与执行连接成闭环。

数据流

输入

玩家通过平板向 ECHO-7 提问或提出行动请求。

注入

聊天管理器把系统提示、摘要与当前 Facts 组合进请求上下文。

生成

模型返回玩家可见文本,必要时在末尾附带结构化命令。

校验

命令解析器和路由器检查格式、类型、目标、权限与剧情门槛。

执行

通过 Receiver 驱动任务、旁白、解锁状态、场景对象或结局门槛。

回写

交互结果、任务变化和剧情节点继续更新 Facts,供下一轮对话使用。

时序图展示玩家消息进入 LLMChatManager 后,如何依次写入聊天日志、调用模型服务、触发 ContextMemory 总结,并通过 SaveManager 持久化。

真实代码节选整理:LLMChatManager.SendUserMessage 把玩家输入、Facts 授权、模型回复、命令解析和兜底执行串成同一条链路;调试口令在公开页面中已脱敏。

public void SendUserMessage(string text, System.Action<string, bool> callback)
{
    Stopwatch stopwatch = Stopwatch.StartNew();

    fullChatLog.Add(new Message { role = "user", content = text });
    SaveManager.Instance?.SaveGame();

    if (contextMemory != null)
        contextMemory.OnMessageAdded(fullChatLog[fullChatLog.Count - 1]);

    ILLMService activeService = GetActiveService();
    if (activeService == null)
    {
        callback?.Invoke("系统错误:服务未连接", false);
        return;
    }

    bool hasDebugKey = ContainsDebugKeyToken(text); // [debug-token]
    bool allowCommandExecutionThisTurn =
        enableFunctionCalling && (!commandExecutionRequiresDebugKey || hasDebugKey);
    bool allowCommandsByStoryState =
        !hasDebugKey && (!restrictCommandsWithoutDebugKey || IsStoryCommandStateUnlocked());

    if (enableChatDrivenStoryFacts && TryDetectExposureClaim(text))
    {
        contextMemory?.AddOrUpdateFact(
            restrictCommandsRequireFactKey,
            restrictCommandsRequireFactValueContains,
            "Chat",
            true);
        allowCommandsByStoryState = !hasDebugKey && IsStoryCommandStateUnlocked();
    }

    StartCoroutine(activeService.SendMessage(text, (reply, success) =>
    {
        string visibleReply = reply;
        LLMCommandEnvelope envelope = null;
        bool hasEnvelope = enableFunctionCalling
            && LLMCommandParser.TryExtract(reply, out visibleReply, out envelope);

        if (success)
        {
            fullChatLog.Add(new Message { role = "assistant", content = visibleReply });
            SaveManager.Instance?.SaveGame();
            if (contextMemory != null)
                contextMemory.OnMessageAdded(fullChatLog[fullChatLog.Count - 1]);

            if (allowCommandExecutionThisTurn && hasEnvelope
                && envelope?.commands?.Length > 0
                && (hasDebugKey || allowCommandsByStoryState))
            {
                if (commandRouter == null) commandRouter = FindObjectOfType<LLMCommandRouter>(true);
                commandRouter?.ExecuteAll(envelope.commands);
            }

            if (enablePersuasionFallback
                && (hasDebugKey || allowCommandsByStoryState)
                && ShouldFallbackExecute(text, visibleReply, envelope))
            {
                ExecutePersuasionEffects(true);
            }
        }

        stopwatch.Stop();
        callback?.Invoke(visibleReply, success);
    }));
}

边界原则

  • 模型可以解释线索,但不能单方面宣布系统状态已经改变。
  • 关键世界事实写入 Facts,以系统记录为准,而不是以角色台词为准。
  • 玩家可见文本和可执行命令分离,避免自然语言歧义直接触发副作用。
  • 所有命令都需要通过白名单和目标 ID 路由,关键节点还要绑定剧情条件。
  • 当命令被拒绝或格式错误时,游戏仍需用任务 UI、旁白或触发器兜底推进。

这个结构牺牲了一部分“AI 万能”的幻觉,但换来的是更适合游戏原型的可控性。对于毕业设计而言,它也更容易被拆解、展示和验证。