欢迎光临
我们一直在努力

我用 Dify + EdgeOne 给自己造了个不会鸽的“AI 守秘人“——从 0 到 1 搭建可多人并发的克苏鲁跑团

文章目录

    • 写在前面:这个项目想解决一个很真实的问题
    • 一、产品设计:把"守秘人"拆成可工程化的东西
      • 1.1 一局完整体验长这样
      • 1.2 把这些落到 Dify 工作流上
    • 三、第二步:定义会话变量(最关键的一步)
    • 四、第三步:搭建工作流
      • 4.1 整体节点结构预览
      • 4.2 节点 1:问题分类器
      • 4.3 节点 2:类别 1 分支 — 创建角色
        • Step 1:Code 节点掷属性
        • Step 2:变量赋值节点(写入会话变量)
        • Step 3:LLM 节点(开场叙事)
        • Step 4:回答节点
      • 4.4 类别 2 分支 — 判定行动(最核心)
        • Step 1:代码节点——提取属性
        • Step 2:LLM 节点——判定决策
        • Step 3:Code 节点(解析 JSON + 掷 D100)
        • Step 4:LLM 节点(生成判定后的剧情)
        • Step 5:Code 节点(解析后果标记)
        • Step 6:变量赋值节点
        • Step 7:回答节点
      • 4.5 节点 4:类别 3 分支 — 普通叙事
      • 4.6 节点5:类别 4 分支 — 查询状态
        • Step 1:代码节点 — 状态摘要
        • Step 2:LLM 节点 — 状态查询
        • Step 3:直接回复节点
      • 4.7 游戏体验
    • 五、第四步:用 EdgeOne Pages 部署前端
      • 5.1 在 Dify 拿到 API 凭证
      • 5.2 一键部署 EdgeOne Pages
      • 5.3 验证
      • 5.4 EdgeOne 部署后的实战测试
    • 六、第五步:把剧本塞进知识库(让守秘人有"剧本可演")
      • 6.1 准备剧本:《雾港谜案》
      • 6.2 创建知识库
      • 6.3 在叙事节点接知识库
    • 七、第六步:给Dify发布到Marketplace
    • 八、第七步:并发架构——这玩意儿真的能给一群人玩吗?
      • 8.1 一个关键问题:状态隔离
      • 8.2 整套系统的请求流
      • 8.3 真正的并发上限在哪?
    • 九、为什么是 Dify + EdgeOne?这套组合的真实价值
      • 9.1 Dify 解决"AI 工作流编排的复杂度"
      • 9.2 EdgeOne Pages 解决"前端工程的所有杂活"
      • 9.3 "改一处生效一处"的迭代体验
      • 9.4 全球部署 = 给海外朋友玩没压力
    • 十、写在最后
    • 附录:关键链接

写在前面:这个项目想解决一个很真实的问题

跑过团的朋友圈里有句话:DM(守秘人)一鸽,跑团成佛。 TRPG(桌面角色扮演)这个爱好的最大门槛不是规则,不是角色卡,是找一个不会鸽的DM。一个合格的守秘人要会写剧本、会演 NPC、会临场应变、还得能掌控节奏。圈内常态是约一次团需要在群里拉锯两周,DM 临阵放鸽子大家又散场。 我自己作为后端工程师,半年来在 ChatGPT 上"陪跑"过几次克苏鲁,体验是这样的:

  • 氛围尚可,文笔也不差
  • 但它会撒谎——你说"我成功撬开锁",它就让你成功;你说"我失败了",它真就让你失败
  • 没有真正的状态——你掉了血下一轮就忘了,物品栏吃过的药水还在
  • 没有规则约束——一个高敏捷的小偷和一个力大无穷的莽汉,在 ChatGPT 这里没有区别

直到我发现 Dify 的 会话变量(Conversation Variables)——一个跨对话轮持久化、且按对话隔离的状态容器。再加上 EdgeOne Pages 的一键全球部署,我意识到:这两个东西凑一块儿,正好可以做一个真正能用的 AI 守秘人。 折腾了一个周末,最终交付物是这样一个产品:

  • 公网可访问的 Web 应用,链接发给朋友就能玩
  • 真掷骰子(D100),按 COC 7e 规则判定成功失败
  • HP/SAN/物品栏全部持久化,会话间不丢
  • 支持多人同时玩,每个人的角色卡互相隔离
  • 一个完整的克苏鲁短剧本《雾港谜案》,30 分钟通关

一、产品设计:把"守秘人"拆成可工程化的东西

在动手之前,先把"AI 守秘人到底要做什么"定义清楚——这一步想透了,后面的工作流就只是把它翻译成节点。

1.1 一局完整体验长这样

1.PNG 注意几个关键设计:

  • 判定真实:D100 是真的随机数,不是 LLM 自己说成功就成功
  • 状态真实:SAN 减少是真的减少,会保存到下一轮
  • 后果真实:失败不是"你失败了"四个字,而是世界发生变化
  • 1.2 把这些落到 Dify 工作流上

    我们需要一个 Chatflow(不是 Workflow,因为要多轮对话),它的内部结构是: 2.PNG 会话变量是这套东西的核心——它是 Dify 在 v0.7.0 引入的特性,允许在同一个 Chatflow 会话内存储指定信息,并在多轮对话中持续引用,支持字符串、数字、对象、数组等结构化类型。这正好对应"角色卡 + HP + SAN + 物品栏 + 当前场景"这样的状态需求。 更关键的一点(后面还会展开讲):会话变量按 conversation_id 隔离。也就是说,玩家 A 和玩家 B 同时在玩,他们的角色卡是各自独立的——这是这个项目能"给一群人玩"的根本原因。

    用途 推荐模型
    意图分类 / 状态查询(高频低质) deepseek-v4-flash
    叙事生成(核心,省不得) deepseek-v4-flash
    3.png
    4.png
    进入工作流编辑器,你会看到默认只有 开始 → LLM → 回答 三个节点。下一步我们把它改造成完整的守秘人。
    5.png

    三、第二步:定义会话变量(最关键的一步)

    这是整个项目的"内存"。在工作流编辑器左侧边栏找到 “会话变量”(CONVERSATION VARIABLES),按下表逐个添加:

    变量名 类型 默认值 / Schema 用途
    character Object (展开见下方) 角色卡:姓名、职业、6 项属性
    hp Number 0 生命值
    san Number 0 理智值(克苏鲁灵魂)
    inventory Array[String] [] 物品栏
    scene_id String prologue 当前场景
    turn Number 0 行动轮数
    is_character_created Number 0 0=未建角,1=已建角

    character 的子字段 schema

    属性名 类型 默认值
    name string (空)
    occupation string (空)
    str number 0
    dex number 0
    int number 0
    pow number 0
    con number 0
    edu number 0
    注意:会话变量和"环境变量"“系统变量”“用户变量"是四个不同的概念。这里必须用会话变量,因为只有它做了"按对话隔离 + 跨轮持久化”。
    6.png
    7.png

    四、第三步:搭建工作流

    4.1 整体节点结构预览

    最终搭出来是这样: 8.png

    4.2 节点 1:问题分类器

    在画布上拖一个 问题分类器(Question Classifier) 节点,连接在 开始 之后。配置:

    • 输入变量:sys.query
    • 模型:DeepSeek
    • 类别:
    类别 ID 名字 描述(写给 LLM 看的)
    1 创建角色 玩家说"开始"、“开始游戏”、“重新建角”、“重投属性”、"创建角色"等表示要开始或重置游戏的话
    2 需要判定的行动 玩家试图做有失败可能的事,例如撬锁、潜行、说谎、聆听、搜查、攀爬、急救、说服、躲避、攻击、寻找隐藏物
    3 普通叙事行动 玩家说话、移动、不会失败的简单动作,例如走过去、看一眼、问 NPC 一个问题、自言自语
    4 查询状态规则 玩家询问自己当前状态、规则、物品栏,例如"我现在 HP 多少"、“我有什么”、“什么是理智值”
    9.png

    4.3 节点 2:类别 1 分支 — 创建角色

    Step 1:Code 节点掷属性

    从分类器的 类别 1 输出连出一个 代码执行节点。语言选 Python3,代码:

    import random

    def main() > dict:
    """COC 7e 简化建角"""

    def roll_3d6_x5():
    return sum(random.randint(1, 6) for _ in range(3)) * 5

    def roll_2d6p6_x5():
    return (sum(random.randint(1, 6) for _ in range(2)) + 6) * 5

    str_val = roll_3d6_x5()
    dex_val = roll_3d6_x5()
    int_val = roll_2d6p6_x5()
    pow_val = roll_3d6_x5()
    con_val = roll_3d6_x5()
    edu_val = roll_2d6p6_x5()

    hp = (con_val + roll_3d6_x5()) // 10 + 5
    san = pow_val

    character = {
    "name": "调查员",
    "occupation": "私家侦探",
    "str": str_val,
    "dex": dex_val,
    "int": int_val,
    "pow": pow_val,
    "con": con_val,
    "edu": edu_val
    }

    summary = (
    f"力量{str_val} 敏捷{dex_val} 智力{int_val} "
    f"意志{pow_val} 体质{con_val} 教育{edu_val} | "
    f"HP:{hp} SAN:{san}"
    )

    return {
    "character": character,
    "hp": hp,
    "san": san,
    "inventory": ["左轮手枪(6发)", "煤油灯", "笔记本", "怀表"],
    "summary": summary
    }

    输出变量配置(这一步千万别漏,否则下游拿不到值):

    • character (Object)
    • hp (Number)
    • san (Number)
    • inventory (Array[String])
    • summary (String)

    10.png

    Step 2:变量赋值节点(写入会话变量)

    接一个 变量赋值(Variable Assigner) 节点,把 Code 的输出写进会话变量:

    目标会话变量 来源 操作
    character 掷骰创建角色.character 覆写
    hp 掷骰创建角色.hp 覆写
    san 掷骰创建角色.san 覆写
    inventory 掷骰创建角色.inventory 覆写
    is_character_created 1(常量) 覆写
    11.png
    Step 3:LLM 节点(开场叙事)

    接一个 LLM节点,命名 开场叙事,模型选 DeepSeek。 System Prompt:

    你是克苏鲁神话TRPG的守秘人(Keeper)。你的职责是营造氛围、推进剧情、引导玩家。

    你的写作风格遵循以下铁律:
    1. 暗示性恐怖优于直白描写。用气味、声音、不协调的细节制造不安。
    2. 永远不替玩家做决定。结尾用"你怎么做?"或具体的开放式选择。
    3. 每段叙事控制在 200 字以内,节奏紧凑。
    4. 1920 年代英格兰背景,对话用书面化的英式表达。
    5. 维持冷峻、潮湿、宿命感的基调。

    当前场景:序章 – 雾港来信。
    玩家职业:私家侦探。
    背景设定:1923 年 10 月,英格兰海港小城阿卡姆。最近一周三人离奇失踪。
    玩家是私家侦探,刚收到老友——船长威尔逊的紧急求助信。
    雨夜,玩家此刻站在威尔逊家门口,门虚掩着,屋内漆黑一片。

    User Prompt:

    玩家刚刚创建了角色,属性如下:
    {{掷骰创建角色.summary}}

    请按以下结构生成开场:
    1. 用 1-2 句话呈现角色的属性数值(用括号或破折号标注,让玩家知道自己掷出了什么)
    2. 用电影开场的笔法描述当前场景(雨夜、门、声音、潮湿、不祥)
    3. 以"你怎么做?"收束

    12.png

    Step 4:回答节点

    接一个 直接回复 节点,输出LLM消息 13.png 类别 1 分支完成。

    4.4 类别 2 分支 — 判定行动(最核心)

    这是整个项目的技术亮点,也是和"用 DeepSeek 直接玩跑团"最大的区别。这一分支共 6 个节点:

    分类器[分类2] → 提取属性 → 判定决策 → D100判定 → 判定叙事 → 解析后果 → 变量赋值 → 直接回复

    Step 1:代码节点——提取属性

    操作步骤

  • 点分类器 分类 2 出口的 + → 选 代码执行
  • 节点重命名为 提取属性
  • 语言 选 Python3
  • 输入变量 添加一项:
  • 变量名:character
  • 来源:选 会话变量 → character
  • 代码 区域,把默认代码全部删掉,粘贴:
  • def main(character: dict) > dict:
    return {
    "attr_text": (
    f"STR={character.get('str', 0)}, "
    f"DEX={character.get('dex', 0)}, "
    f"INT={character.get('int', 0)}, "
    f"POW={character.get('pow', 0)}, "
    f"CON={character.get('con', 0)}, "
    f"EDU={character.get('edu', 0)}"
    )
    }

  • 输出变量 添加一项:
  • 变量名:attr_text
  • 类型:string
  • 14.png

    Step 2:LLM 节点——判定决策

    操作步骤

  • 点 提取属性 节点输出的 + → 选 LLM
  • 节点重命名为 判定决策
  • 模型 选 deepseek-v4-flash 或 deepseek-chat(决策类任务不需要强推理,要快)
  • 上下文 留空(不接知识库)
  • SYSTEM 提示词

    你是 COC 7e 守秘人的判定助理。根据玩家的行动描述,判断该使用哪种检定。

    可用检定类型及对应属性:
    – 聆听 / 侦查 / 图书馆 → POW
    – 潜行 / 躲避 / 攀爬 → DEX
    – 急救 / 锁匠 → DEX
    – 话术 / 说服 / 心理学 → POW
    – 知识 / 推理 → INT
    – 体力 → STR

    难度分级:
    – regular: 简单情境(如安静环境聆听)
    – hard: 困难情境(如嘈杂环境聆听 / 锁很复杂)
    – extreme: 极限情境(如风暴中聆听 / 大师级锁)

    严格输出 JSON,禁止任何额外文字、禁止 Markdown 代码块包裹。

    输出格式示例:
    {"check_type": "聆听", "target_attr": "pow", "difficulty": "regular", "reason": "玩家试图听门后的声音,环境安静"}

    target_attr 必须是以下小写字符串之一:str / dex / int / pow / con / edu
    difficulty 必须是以下字符串之一:regular / hard / extreme

    USER 提示词

    玩家行动:【sys.query】

    玩家属性:
    【提取属性 / attr_text】

    15.png

    Step 3:Code 节点(解析 JSON + 掷 D100)

    接一个 代码执行 节点,命名 D100判定。代码:

    import json
    import re
    import random

    def main(decision_text: str, character: dict) > dict:
    """解析 LLM 决策 + 掷 D100"""

    # 容错:兼容 ```json … ```包裹 / DeepSeek-R1 的 <think> 标签
    match = re.search(r'\\{[\\s\\S]*\\}', decision_text)
    if not match:
    return {
    "roll": 100,
    "threshold": 0,
    "level": "失败",
    "is_success": 0,
    "check_type": "未知检定",
    "narration_hint": "[判定解析失败,按失败处理]"
    }

    decision = json.loads(match.group(0))
    target_attr = decision.get("target_attr", "pow")
    difficulty = decision.get("difficulty", "regular")
    check_type = decision.get("check_type", "未知检定")

    attr_value = character.get(target_attr, 50)

    # 计算阈值
    if difficulty == "extreme":
    threshold = attr_value // 5
    elif difficulty == "hard":
    threshold = attr_value // 2
    else:
    threshold = attr_value

    # 掷骰
    roll = random.randint(1, 100)
    is_success = 1 if roll <= threshold else 0

    # COC 7e 成功等级
    if roll <= attr_value // 5:
    level = "极限成功"
    elif roll <= attr_value // 2:
    level = "困难成功"
    elif roll <= attr_value:
    level = "普通成功"
    elif roll == 100 or (roll >= 96 and attr_value < 50):
    level = "大失败"
    else:
    level = "失败"

    narration_hint = (
    f"[{check_type}检定 难度:{difficulty}] "
    f"D100={roll}, 目标≤{threshold}({target_attr.upper()}={attr_value}), "
    f"结果: {level}"
    )

    return {
    "roll": roll,
    "threshold": threshold,
    "level": level,
    "is_success": is_success,
    "check_type": check_type,
    "narration_hint": narration_hint
    }

    输入变量配置:

    • decision_text ← 判定决策.text
    • character ← 会话变量 character

    输出变量:roll (Number)、is_success (Number)、level (String)、check_type (String)、narration_hint (String)、threshold (Number) 16.png

    Step 4:LLM 节点(生成判定后的剧情)

    接一个 LLM 节点,命名 判定叙事,模型用 DeepSeek(核心叙事节点)。 System Prompt:

    你是克苏鲁守秘人。基于玩家行动和判定结果生成剧情。

    铁律:
    1. 判定结果不可推翻:成功就描写成功的过程与发现,失败就描写失败的代价。
    2. 失败要有后果:不是只描写"你失败了",要让世界发生变化(惊动了什么、错过了什么、暴露了自己、误判了线索)。
    3. 维持克苏鲁基调:暗示性恐怖优于直白描写。
    4. 字数 250 字以内,结尾以开放式选择收束。

    如果剧情触发以下后果,必须在叙事末尾用方括号标记:
    – [SAN_LOSS: 数值] 例如看到恐怖事物
    – [HP_LOSS: 数值] 例如受伤
    – [ITEM_GAINED: 物品名] 例如捡到东西
    – [SCENE: 新场景ID] 例如进入下一场景

    不要在正文里出现这些标记,只在叙事的最末尾用单独一行列出。

    User Prompt:

    【玩家行动】
    {{sys.query}}

    【判定结果】
    {{D100判定.narration_hint}}
    是否成功:{{D100判定.is_success}}

    【角色当前状态】
    HP: {{hp}}
    SAN: {{san}}
    物品: {{inventory}}

    【当前场景】
    {{scene_id}}

    17.png

    Step 5:Code 节点(解析后果标记)

    LLM 输出的叙事里会带一些方括号标记(比如 [SAN_LOSS: 2]、[HP_LOSS: 3]),我们用一个代码节点把这些标记解析出来,扣减玩家的 HP/SAN,然后返回干净的叙事正文给玩家看。 操作步骤

  • 点 判定叙事 节点输出的 + → 选 代码执行
  • 节点重命名为 解析后果
  • 语言 选 Python3
  • 输入变量(4 个,必填)

    变量名 类型 来源
    narration String 选 判定叙事 → text
    current_hp Number 选 会话变量 → hp
    current_san Number 选 会话变量 → san
    current_inventory Array[String] 选 会话变量 → inventory
    代码

    import re

    def main(narration: str, current_hp: int, current_san: int, current_inventory: list) > dict:
    """从叙事末尾解析方括号标记,返回新状态"""

    san_loss = 0
    hp_loss = 0
    new_items = list(current_inventory)
    new_scene = ""

    # 解析 SAN 损失
    san_match = re.search(r'\\[SAN_LOSS:\\s*(\\d+)\\]', narration)
    if san_match:
    san_loss = int(san_match.group(1))

    # 解析 HP 损失
    hp_match = re.search(r'\\[HP_LOSS:\\s*(\\d+)\\]', narration)
    if hp_match:
    hp_loss = int(hp_match.group(1))

    # 解析物品获得
    item_matches = re.findall(r'\\[ITEM_GAINED:\\s*([^\\]]+)\\]', narration)
    for item in item_matches:
    new_items.append(item.strip())

    # 解析场景切换
    scene_match = re.search(r'\\[SCENE:\\s*([^\\]]+)\\]', narration)
    if scene_match:
    new_scene = scene_match.group(1).strip()

    # 移除方括号标记,返回干净的叙事
    clean_narration = re.sub(
    r'\\[(?:SAN_LOSS|HP_LOSS|ITEM_GAINED|SCENE):[^\\]]+\\]',
    '',
    narration
    ).strip()

    new_hp = max(0, current_hp hp_loss)
    new_san = max(0, current_san san_loss)

    status_change = ""
    if (hp_loss + san_loss) > 0:
    status_change = f"[本轮变化] HP: {current_hp}{new_hp}, SAN: {current_san}{new_san}"

    return {
    "clean_narration": clean_narration,
    "new_hp": new_hp,
    "new_san": new_san,
    "new_inventory": new_items,
    "new_scene": new_scene if new_scene else "no_change",
    "status_change": status_change
    }

    输出变量

    变量名(必须和 return 字段完全一致) 类型
    clean_narration String
    new_hp Number
    new_san Number
    new_inventory Array[String]
    new_scene String
    status_change String
    18.png
    Step 6:变量赋值节点

    把解析出来的新状态写回会话变量:

    目标会话变量(左侧) 操作模式 值来源(右侧)
    hp 覆盖(Overwrite) 解析后果 → new_hp
    san 覆盖(Overwrite) 解析后果 → new_san
    inventory 覆盖(Overwrite) 解析后果 → new_inventory
    19.png
    Step 7:回答节点

    输出:

    {{解析后果.clean_narration}}

    {{解析后果.status_change}}

    20.png 类别 2 分支完成。这是整个项目的技术核心,每一行代码都在实现"AI 不能撒谎"。

    4.5 节点 4:类别 3 分支 — 普通叙事

    最简单。LLM 节点,模型用 DeepSeek:

    你是克苏鲁守秘人。玩家进行了一个不需要掷骰的普通行动:{{sys.query}}

    【角色状态】HP:{{hp}} SAN:{{san}} 物品:{{inventory}}
    【当前场景】{{scene_id}}

    进行纯叙事推进,维持氛围。字数 200 字以内,结尾开放式选择。

    后面接回答节点。 21.png 22.png

    4.6 节点5:类别 4 分支 — 查询状态

    Step 1:代码节点 — 状态摘要

    操作步骤

  • 点分类器 分类 4 出口的 + → 选 代码执行
  • 节点重命名为 状态摘要
  • 语言 选 Python3
  • 输入变量

    变量名 类型 来源
    character Object 会话变量 → character
    hp Number 会话变量 → hp
    san Number 会话变量 → san
    inventory Array[String] 会话变量 → inventory
    代码

    def main(character: dict, hp: int, san: int, inventory: list) > dict:
    """把所有状态打包成一个 LLM 友好的字符串"""

    # 物品栏:列表转换成顿号分隔的字符串
    inventory_text = "、".join(inventory) if inventory else "(空)"

    status_text = f"""【角色卡】
    姓名:
    {character.get('name', '调查员')}
    职业:
    {character.get('occupation', '私家侦探')}
    力量 STR:
    {character.get('str', 0)}
    敏捷 DEX:
    {character.get('dex', 0)}
    智力 INT:
    {character.get('int', 0)}
    意志 POW:
    {character.get('pow', 0)}
    体质 CON:
    {character.get('con', 0)}
    教育 EDU:
    {character.get('edu', 0)}

    【当前状态】
    HP: {hp}
    SAN:
    {san}
    物品栏:
    {inventory_text}"""

    return {
    "status_text": status_text
    }

    输出变量

    变量名 类型
    status_text String
    Step 2:LLM 节点 — 状态查询

    操作步骤

  • 点 状态摘要 节点输出的 + → 选 LLM
  • 节点重命名为 状态查询
  • 模型 选 deepseek-v4-flash 或 deepseek-chat
  • SYSTEM 提示词

    你是克苏鲁神话TRPG的守秘人。玩家在询问自己的角色状态或装备。

    回答风格:
    1. 用守秘人的口吻(带点神秘感、1920 年代英式风格)
    2. 只回答玩家询问的部分,不要把整张角色卡全列出来
    3. 简短,2-3 句话即可
    4. 如果玩家问 SAN 很低,可以暗示其精神状态("你感到指尖在微微颤抖…")

    例如:
    – 玩家问 "我现在多少血" → "调查员,你的伤势…HP 还剩 9 点。"
    – 玩家问 "我有什么东西" → "翻翻你的口袋:一把左轮、半截煤油灯、笔记本和那块旧怀表。"
    – 玩家问 "我的智力多少" → "你的智力 INT 是 75 —— 一个不错的分数,但在某些事情面前,再聪明的头脑也是徒劳。"

    USER 提示词 输入框清空,按这个顺序操作:

  • 手打:玩家询问:(中文冒号)
  • 按 / → 选 **用户输入 → **query → 插入 sys.query 胶囊
  • 回车两次(空一行)
  • 按 / → 选 状态摘要 → status_text → 插入胶囊
  • 最终 USER Prompt 看起来:

    玩家询问:【sys.query】

    【状态摘要 / status_text】

    23.png

    Step 3:直接回复节点
  • 点 状态查询 输出的 + → 选 直接回复
  • 节点重命名为 状态回复
  • 回复内容:用 / 斜杠菜单插入 状态查询 → text 胶囊
  • 【状态查询 / text】

    24.png

    4.7 游戏体验

    首先我输入了“开始游戏”,走到了类别1分支并生成了开场叙事。 25.png 然后输入"我推门进去"——这是普通行动,走类别 3。 26.png 再试"我贴在门上听里面的声音"——走类别 2,触发 D100 检定。 27.png 再试"检查周围环境,寻找其他线索"——走类别 2,触发 D100 检定。 28.png 输入“我的智力和血量是多少”,此时会走类别4,查询状态 29.png

    五、第四步:用 EdgeOne Pages 部署前端

    工作流跑通了,但目前只能在 Dify 工作台里玩。要给朋友玩,要部署成可访问的网页。EdgeOne Pages 上有官方 Dify Frontend Starter 模板,专门解决这件事。

    5.1 在 Dify 拿到 API 凭证

    回到 Chatflow 编辑器,点击右上角 “发布” 发布工作流 30.png 31.png 然后点击左上角应用名 → 弹出的面板里找到 “访问API” 区块:

    • 复制 API****服务器(云端版固定是 https://api.dify.ai/v1)
    • 点击 API****密钥 → 创建一个新的 Key,复制下来(形如 app-xxxxxxxxxxxxxxxx)

    32.png

    5.2 一键部署 EdgeOne Pages

    打开模板页:https://pages.edgeone.ai/templates/dify-frontend 点击 Deploy 按钮。 33.png 进入控制台后: Step 1:激活Pages 点击 **Activate Now按钮 **进行激活

    34.png Step 2:授权****GitHub 点击下拉菜单里的 “+ Add GitHub account/organization”,浏览器会跳转到 GitHub 授权页进行授权

    35.png 36.png 选择授权范围:

    • All repositories(所有仓库,简单省事)
    • Only select repositories(只选指定的,更安全)
    • 如果你只是为了部署这个项目,选 All repositories 最快‘

    随后点击Install

    37.png Step 3:Configure Project 填写:

    • Project name:cthulhu-keeper(或随你喜欢)
    • Repository** name**:同上
    • Acceleration region:Global
    • Repository** type**:Private

    关键的****环境变量(必填三个):

    变量名 说明
    APP_KEY app-xxxxxxxx 上一步从 Dify 拿的 API Key
    API_URL https://api.dify.ai/v1 Dify Service API Endpoint
    NEXT_PUBLIC_APP_TYPE chat Chatflow 应用填 chat
    配置完成后点击 Create。等 1-2 分钟,部署完成。
    38.png

    5.3 验证

    点击Domain后,控制台会给你一个形如 https://xxxx.edgeone.dev 的链接。点开,输入"开始游戏"进行测试 60.png 61.png

    如果看到了开场叙事和你的角色属性,说明整个链路走通了:浏览器 → EdgeOne 边缘节点 → Dify API → LLM → 流式返回。 41.png

    5.4 EdgeOne 部署后的实战测试

    打开 https://cthulhu-keeper-xxx.edgeone.app进行测试 第一条消息直接输入 “开始游戏”,让守秘人触发建角流程。 55.png 输入 “我会推门进去”——这是个不需要掷骰的简单动作,应该走类别 3 纯叙事分支。 56.png 输入 “打开那扇暗门”——延续上一轮的钩子。 57.png 输入:**“原地驻足,凝神仔细听地底传来的动静”,**会走 D100 掷骰判定

    58.png 最后输入 “我现在的状态怎么样”——验证状态查询分支。 59.png 整个流程流畅连贯

    六、第五步:把剧本塞进知识库(让守秘人有"剧本可演")

    到这里基本能玩了,但 LLM 是临场发挥剧情,体验不够稳定。最好的做法是把剧本骨架放进 Dify 知识库,叙事节点引用它——LLM 仍然负责描写,但剧情走向由你掌控。

    6.1 准备剧本:《雾港谜案》

    新建一个文本文件 mist_harbor.md:

    # 《雾港谜案》守秘人剧本

    ## 背景
    1923 年 10 月,英格兰海港阿卡姆。最近一周三人离奇失踪。
    第三位失踪者:船长威尔逊(玩家旧友)。

    ## 场景 1:威尔逊家(scene_id=威尔逊家)
    关键线索:
    – 地下室有印记的木板(侦查 检定)
    – 仪式残留物(图书馆 检定可识别为达贡教派)
    – 半本航海日志(直接发现)

    可触发判定:
    – 聆听(POW):听到地下室声音
    – 侦查(POW):发现地板异常
    – 图书馆(INT):识别墙上印记

    SAN 损失点:
    – 看到墙上印记:1d4
    – 进入地下室:1d6
    – 阅读完整航海日志:1d4+1

    下一场景:码头深夜(线索齐全后)

    ## 场景 2:码头深夜(scene_id=码头深夜)
    关键 NPC:
    – 流浪汉乞丐汤姆:前调查员,已疯,能给关键暗示
    – 港务长哈罗德:贪婪,被胁迫,可被劝反

    可触发判定:
    – 心理学(POW):看出港务长撒谎
    – 话术(POW):让汤姆开口
    – 潜行(DEX):跟踪可疑船只

    SAN 损失点:
    – 听完汤姆的预言:1d4
    – 目睹码头仪式残骸:1d6

    下一场景:废弃灯塔

    ## 场景 3:废弃灯塔(scene_id=废弃灯塔)
    真相:邪教仪式入口,BOSS"低语者"在此。

    三种结局:
    – 成功(理智 ≥ 30 + 持有航海日志):
    封印仪式入口,事件解决,SAN 永久 +5
    – 中性(理智 10-30):
    逃出生天,但终生被噩梦缠身
    – 失败(理智 < 10 或败北):
    永久疯狂,加入邪教

    ## 关键 NPC 性格设定
    – 港务长哈罗德:50 岁,贪婪但有恻隐之心,被邪教威胁。说话时眼神躲闪。
    – 乞丐汤姆:40 岁,已疯,但能在间歇性清醒时给出关键暗示。说话颠三倒四。
    – 邪教祭司"低语者":BOSS,不能正面对抗,只能逃或封印。声音如多人重叠。

    6.2 创建知识库

    Dify 工作台 → 知识库 → 创建知识库 → 上传 mist_harbor.md,选择 经济 模式(这种叙事内容不需要语义太精细),保存并处理。 42.png 43.png 44.png 45.png

    6.3 在叙事节点接知识库

    回到 Chatflow,在 判定叙事 和 开场叙事 节点的配置里,添加上下文 → 选择刚创建的知识库。这样每次生成叙事时,LLM 都会参考剧本骨架。 判定叙事 在判定叙事LLM之前加一个知识检索节点 46.png 知识检索节点选中刚刚创建的知识库 47.png 判定叙事LLM的上下文中引用知识检索,在SYSTEM提示词的结尾加上“【参考剧本片段】引用的上下文” 48.png 开场叙事 开场叙事也需要加上,同样的道理 49.png 更新完成后记得发布更新 50.png

    七、第六步:给Dify发布到Marketplace

    Step 1:导出 DSL 文件 51.png Step 2:去 Creator Center 上传 打开 https://creators.dify.ai/ ,点击提交模版 52.png 等待审核 53.png 54.png

    八、第七步:并发架构——这玩意儿真的能给一群人玩吗?

    这是这个项目和大多数"AI 角色扮演 demo"最大的区别。绝大多数同类项目演示完就完了,根本没法多人同时用。 我们这套方案天然支持并发,但理由值得讲清楚——这也是这篇文章的硬技术含量所在。

    8.1 一个关键问题:状态隔离

    假设玩家 A 和玩家 B 同时打开链接:

    • A 的角色掉到 SAN=10
    • B 的角色刚建好 SAN=60

    他们的状态会不会串? 答案是不会。原因是 Dify 的会话变量按 conversation_id 分桶存储:

    玩家 A 打开网页
    └─ EdgeOne Pages 前端 → Dify API
    └─ Dify 创建 conversation_id = "conv_aaa111"
    └─ 这个 conversation 的会话变量独立存储:
    hp=12, san=10, inventory=[...], scene_id="灯塔"

    玩家 B 同时打开网页
    └─ EdgeOne Pages 前端 → Dify API
    └─ Dify 创建 conversation_id = "conv_bbb222"
    └─ 完全独立的会话变量:
    hp=12, san=60, inventory=[...], scene_id="prologue"

    工作流定义共享(同一份"菜谱"),但执行实例独立(每个人用自己的"锅")。这是经典的 stateless service + stateful storage 架构。

    8.2 整套系统的请求流

    全球用户(A 在北京,B 在旧金山,C 在伦敦)

    [EdgeOne Pages 全球边缘节点]
    ├─ 静态资源(HTML/CSS/JS)从最近节点返回,秒开
    └─ 动态请求(聊天 API)走优化路由

    [Dify Cloud API](云端)
    ├─ 按 conversation_id 创建独立的工作流执行实例
    └─ 各自调用各自的 LLM API

    [LLM 厂商](OpenAI/Anthropic/DeepSeek)

    流式响应回传 → EdgeOne → 浏览器

    这个架构妙在哪:

    • EdgeOne 扛连接:CDN 节点本职就是处理百万级并发连接
    • Dify 扛状态:会话变量按 conversation_id 隔离,PG 后端保证一致性
    • LLM扛智能:是真正的瓶颈,但和你部署在哪没关系

    8.3 真正的并发上限在哪?

    诚实地讲,瓶颈不在 Dify 也不在 EdgeOne,在你的 LLM API限流。 每个玩家发一条消息,会触发 2-3 次 LLM 调用(分类 + 判定决策 + 叙事)。粗算:

    50 个玩家同时玩
    × 平均每 30 秒一轮
    × 每轮 3 次 LLM 调用
    = 每分钟 300 次 LLM 调用

    这个量级下:

    • OpenAI Tier 2 以上:轻松扛
    • Anthropic 新账号:会卡(每分钟 50 次限制)
    • DeepSeek:相对宽松

    九、为什么是 Dify + EdgeOne?这套组合的真实价值

    跑完一遍这个项目,我对这个组合的判断是:它精准解决了 AI 应用从 demo 到产品的最后一公里。

    9.1 Dify 解决"AI 工作流编排的复杂度"

    如果不用 Dify,自己拼这个守秘人大概要:

    • 一个 Python 后端(FastAPI/Flask)
    • 自己实现状态管理(Redis/PG)
    • 自己处理流式输出(SSE)
    • 自己接每个 LLM 的 API(重试、限流、降级)
    • 自己做 conversation 隔离

    我估了一下,从 0 写到能上线,至少 3-5 天。Dify 把这个时间压到 半天,且每个节点的输入输出可视化,调试体验比写代码还顺手。 更关键的是 会话变量这个特性——它帮你把"AI 应用最难的状态管理"用最自然的方式解决了。把 hp/san/inventory 当成普通变量读写,背后所有的并发隔离、持久化、序列化都是 Dify 帮你扛的。

    9.2 EdgeOne Pages 解决"前端工程的所有杂活"

    Dify 自带的 webapp URL 适合自己玩,给陌生人用就需要:

    • 自定义域名 + 自动 HTTPS
    • 全球访问加速
    • 可控的 UI(嵌入 logo、改配色、加埋点)
    • 多对话历史、流式输出、节点追踪面板

    EdgeOne Pages 的 Dify Frontend Starter 把这些全做好了。我特别喜欢两点:

  • 自动适配 4 种应用类型:今天我做 Chatflow,明天换成 Workflow,前端代码一行不改,只改一个环境变量
  • 工作流****节点追踪面板:玩家能看到 AI 此刻在做什么(“正在掷骰…”、“正在生成叙事…”),对长耗时任务体验至关重要
  • 9.3 "改一处生效一处"的迭代体验

    部署完成后,整套系统是这样工作的:

    我改 Dify 工作流(加一个新场景的判定节点)

    点 Publish

    EdgeOne 上的前端无需重新部署,直接生效

    因为前端只是 Dify API 的"壳",所有业务逻辑都在 Dify 里。朋友提需求"加一个新结局",我 5 分钟改完 Dify 就能给他演示,前端动都不用动。

    9.4 全球部署 = 给海外朋友玩没压力

    EdgeOne 是腾讯的全球 CDN/边缘网络,部署一次就在全球节点分发。我把链接发给在新加坡和美国的朋友测过,延迟和国内体验差别极小。这点对出海项目特别有价值。

    十、写在最后

    做这个项目最大的感受:AI 应用的瓶颈早就不是"能不能做出来",而是"能不能让人用上、并发用上、可持续用上"。

    • Dify 解决"AI 工作流编排"——让你专注业务逻辑而非脚手架
    • EdgeOne Pages 解决"AI 应用上线"——让你专注产品体验而非部署运维
    • 两者加在一起 = 从 idea 到公网产品的最短路径

    一个后端工程师,一个周末,从零做出一个能给一群人同时玩、状态可隔离、规则可校验的 AI 跑团守秘人——这在两年前是不可想象的。 如果你也有 AI 创意想验证,强烈建议这套组合走一遍。做 AI 产品比你以为的简单得多,难的是想清楚要做什么,以及把它真的部署给人用。

    附录:关键链接

    • Dify 官网:https://dify.ai
    • EdgeOne Pages Dify 模板:https://pages.edgeone.ai/templates/dify-frontend
    • EdgeOne Pages 部署文档:https://pages.edgeone.ai/document/build-guide
    • 本项目 Dify工作流模板:https://marketplace.dify.ai/templates [Dify x EdgeOne] Cthulhu Keeper – AI TRPG Game Master [ZH](已提交至 Dify Marketplace,搜索 “Cthulhu Keeper”)
    赞(0)
    未经允许不得转载:171主机测评 » 我用 Dify + EdgeOne 给自己造了个不会鸽的“AI 守秘人“——从 0 到 1 搭建可多人并发的克苏鲁跑团
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址