Facts 不是摘要,而是状态锚点

在《回声》中,Facts 用来表达关键世界事实,例如电力状态、门锁状态、道具位置、任务阶段和剧情结论。它们不追求文学表达,而追求短、稳、可验证。

{
  "facts": [
    {
      "key": "道具-钥匙-红",
      "value": "在餐厅后厨的金属柜第二层",
      "source": "Preset",
      "timestamp": 0
    }
  ],
  "counter": 0
}

key 结构采用“域-对象-字段”。例如 世界-电力门-主门-锁状态状态-ECHO7-已被识破。这种写法并不追求文学表达,而是服务于系统读取和实验对照验证。

预置与事件注入

新游戏无存档时,系统可以从 StreamingAssets 读取预置 Facts,使 ECHO-7 在第一轮对话前就知道基础世界状态。随着玩家交互、任务推进或剧情节点触发,FactsSequence 又可以通过 UnityEvent 批量 Add、Update 或 Delete。

Preset

开局注入关键事实,例如钥匙位置、地堡状态、当前阶段目标。

Event

玩家恢复电力、阅读文档、触发旁白或完成任务时更新 Facts。

Prompt

ContextMemoryManager 把当前 Facts 重新组织进 System Prompt。

这种做法的价值在于,玩家看到的线索和模型读取的状态来自同一套系统记录。模型可以解释和重述,回答不再依赖凭空猜测。

严格 JSON 命令

Function Calling 在《回声》中不是让模型“随便调函数”,而是让模型把可见文本与行为请求拆开。玩家看到 text,系统读取 commands

{
  "text": "门已经解锁,我正在为你打开。",
  "commands": [
    { "type": "UnlockState", "targetId": "door_main_lock", "value": true },
    { "type": "Door", "targetId": "door_main", "action": "Open" }
  ]
}

本项目的命令类型包括 UnlockState、Door、Event、Task、SetActive 和 Narration。它们必须指向场景中注册过的目标对象,并经由路由器和 Receiver 作用到 Unity 组件。

真实代码节选:解析器只接受能被 JsonUtility 映射到命令信封的 JSON,路由器再按 targetId 分发到注册过的 Receiver。

private static bool TryParseEnvelope(string json, out LLMCommandEnvelope envelope)
{
    envelope = null;
    try
    {
        envelope = JsonUtility.FromJson<LLMCommandEnvelope>(json);
    }
    catch (Exception)
    {
        envelope = null;
    }

    if (envelope == null) return false;
    if (envelope.commands == null && string.IsNullOrWhiteSpace(envelope.text)) return false;
    return true;
}

public int ExecuteAll(LLMCommand[] commands)
{
    if (commands == null || commands.Length == 0) return 0;

    int executed = 0;
    for (int i = 0; i < commands.Length; i++)
    {
        var cmd = commands[i];
        if (cmd == null) continue;
        if (string.IsNullOrWhiteSpace(cmd.targetId)) continue;

        if (!map.TryGetValue(cmd.targetId, out var receiver) || receiver == null) continue;

        if (receiver.TryExecute(cmd)) executed++;
    }

    if (debugLogs)
        Debug.Log($"[LLMCommandRouter] Executed commands={executed}/{commands.Length}", this);
    return executed;
}

拒绝与兜底

  • 命令格式不合法:只显示可见文本,不执行副作用。
  • 命令类型不在白名单:拒绝执行,并可记录调试日志。
  • targetId 不存在:拒绝执行,避免“说对了但操作错对象”。
  • 剧情门槛不满足:保留角色回应,但不改变关键状态。

调试阶段可以用本地密钥门槛快速测试,但公开站点和论文展示中不应暴露真实密钥。正式流程更适合用 Facts 和剧情阶段作为权限依据,例如只有当 状态-ECHO7-已被识破=是 后,关键结局命令才允许进入执行链路。

真实代码节选:剧情门槛通过 Facts 判断,不把“模型说可以”当成执行凭证。

private bool IsStoryCommandStateUnlocked()
{
    if (contextMemory == null) return false;
    if (contextMemory.facts == null) return false;
    if (string.IsNullOrWhiteSpace(restrictCommandsRequireFactKey)) return true;

    for (int i = 0; i < contextMemory.facts.Count; i++)
    {
        var f = contextMemory.facts[i];
        if (f == null) continue;
        if (!string.Equals(f.key, restrictCommandsRequireFactKey, StringComparison.Ordinal)) continue;

        string v = f.value ?? "";
        if (string.IsNullOrWhiteSpace(restrictCommandsRequireFactValueContains)) return true;
        return v.IndexOf(restrictCommandsRequireFactValueContains, StringComparison.Ordinal) >= 0;
    }

    return false;
}

最重要的安全边界是:模型输出可以提出请求,最终是否执行仍由游戏系统决定。