标签: 智能NPC

  • RenPy接入ChatGLM做恋爱选项

    本文解决什么问题

    做视觉小说、恋爱模拟原型时,最费脑的往往不是画面,而是”每个场景该给玩家哪几个选项”。手写分支越堆越多,还容易前后不一致。这篇教程要解决的就是:在 RenPy 里接入 ChatGLM 接口,让 AI 根据当前好感度实时生成恋爱对话选项,并落地成一个可运行的分支 Demo。

    • 适合谁:会一点 RenPy 脚本、想给恋爱/乙女原型加”动态选项”的独立开发者。
    • 不适合:想要开箱即用商业插件、或完全没碰过 Python 的读者。
    • 你能拿到:script.rpy + ai_client.py 两个文件、示例 prompt、本地运行步骤,以及我实测踩到的五个坑及处理。

    实验环境(作者已实机验证):RenPy 8.3.4(内置 Python 3.9)/ ChatGLM 开放平台 glm-4-flash 模型 / Windows 11 / 测试日期 2026-06。下文所有代码、报错、参数均在该环境跑通,版本敏感处已标注,换版本请自行复核。

    准备 ChatGLM API Key 与 RenPy 项目结构

    先到智谱 ChatGLM 开放平台(open.bigmodel.cn,访问日期 2026-06)注册并创建 API Key。接口地址为 https://open.bigmodel.cn/api/paas/v4/chat/completions,鉴权用 Authorization: Bearer <你的Key>glm-4-flash 目前有免费额度,做原型足够。

    关键安全原则:绝不把 Key 硬编码进 .rpy。RenPy 打包(distribute)时会把 game/ 目录下几乎所有文件塞进发行包,玩家解包就能看到明文 Key。开发期我用环境变量读取,正式上线则应通过你自己的服务器中转(游戏端只连你的代理,永远不下发真 Key)。

    项目结构(RenPy 会把 game/ 目录加入 Python 搜索路径,因此 ai_client.py 放这里可直接 import):

    你的项目/
    ├── game/
    │   ├── script.rpy      # 剧情脚本 + 菜单逻辑
    │   └── ai_client.py    # 纯 Python 的 ChatGLM 请求封装
    └── ...

    Windows 下临时设置环境变量(当前终端有效),再从这个终端启动 RenPy Launcher:

    # PowerShell,仅当前会话有效
    $env:CHATGLM_API_KEY = "你的Key"
    # 然后从同一个终端打开 RenPy SDK 的 renpy.exe 启动器

    步骤一:封装 HTTP 请求函数,按好感度生成 3 个选项

    第一个大坑先说在前面:RenPy 8 内置的 Python 里没有 requests,直接 import requests 会报 ModuleNotFoundError。我改用标准库 urllib.request,零依赖、不用往包里塞第三方库。下面是 ai_client.py

    # game/ai_client.py  —— 纯标准库,RenPy 8.3 / Python 3.9 实测可用
    import os
    import json
    import urllib.request
    import urllib.error
    
    API_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
    
    def _post(payload, timeout=20):
        """向 ChatGLM 发一次非流式请求,返回解析后的 dict。"""
        api_key = os.environ.get("CHATGLM_API_KEY", "")
        if not api_key:
            raise RuntimeError("没读到 CHATGLM_API_KEY,请检查环境变量")
    
        headers = {
            "Authorization": "Bearer " + api_key,
            "Content-Type": "application/json",
        }
        # 关键:ensure_ascii=False 后再按 utf-8 编码,否则中文会被转义/乱码
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        req = urllib.request.Request(API_URL, data=body, headers=headers, method="POST")
    
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")   # 显式 utf-8 解码,别用默认编码
        return json.loads(raw)
    
    
    def gen_options(affection, npc_name, history, n=3):
        """按好感度让 AI 产出 n 个恋爱选项,返回字符串列表。失败时抛异常,交上层兜底。"""
        system = (
            "你是恋爱视觉小说的选项设计师。只输出一个 JSON 数组,"
            "数组里是 %d 个中文对话选项字符串,不要任何多余解释、不要代码块。"
            % n
        )
        user = (
            "角色:%s。玩家当前对其好感度为 %d(范围 0-100,越高越亲密)。"
            "请生成 %d 个玩家可对该角色说的话,语气随好感度变化。"
            % (npc_name, affection, n)
        )
        messages = [{"role": "system", "content": system}]
        messages.extend(history[-4:])          # 只带最近两轮,控制上下文长度
        messages.append({"role": "user", "content": user})
    
        data = _post({
            "model": "glm-4-flash",
            "messages": messages,
            "temperature": 0.8,
        })
        text = data["choices"][0]["message"]["content"]
        return _parse_options(text, n)

    这里 _parse_options 单独抽出来,是因为大模型返回的 JSON 并不稳定,步骤二细说。

    步骤二:把 AI 结果解析成 RenPy 菜单,并绑定好感度与兜底文案

    模型有时乖乖返回 ["选项A","选项B","选项C"],有时会裹上 ```json 代码块,甚至多写一句”以下是选项:”。所以解析要多层兜底,任何一步失败都不能让游戏崩

    # game/ai_client.py 续
    import re
    
    FALLBACK = ["……(微笑着看向对方)", "我们聊点别的吧。", "(保持沉默)"]
    
    def _parse_options(text, n):
        text = text.strip()
        # 1) 剥掉可能的 ```json ... ``` 外壳
        text = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", text).strip()
        # 2) 优先按 JSON 数组解析
        try:
            arr = json.loads(text)
            if isinstance(arr, list) and arr:
                opts = [str(x).strip() for x in arr if str(x).strip()]
                if opts:
                    return (opts + FALLBACK)[:n]
        except (json.JSONDecodeError, TypeError):
            pass
        # 3) 退化方案:按行拆,去掉行首的序号/符号
        lines = [re.sub(r"^\s*[\d\.\-、)]+\s*", "", ln).strip()
                 for ln in text.splitlines() if ln.strip()]
        lines = [ln for ln in lines if ln]
        if lines:
            return (lines + FALLBACK)[:n]
        # 4) 全挂了就用兜底文案,保证菜单永远能显示
        return FALLBACK[:n]

    script.rpy 里用 renpy.display_menu 动态构造菜单,并把选择结果记进历史、按选项调整好感度:

    # game/script.rpy
    init python:
        import ai_client   # game/ 在搜索路径里,可直接 import
    
    # 存档要能保留的变量,一律用 default 声明(这点很重要,见踩坑五)
    default affection = 30
    default chat_history = []   # 形如 [{"role":"user","content":"..."}, ...]
    
    label ai_choice:
        "林夏" "(她抬起头)今天……你想说点什么?"
    
        python:
            try:
                options = ai_client.gen_options(affection, "林夏", chat_history)
            except Exception as e:
                # 网络/超时/解析异常,统一降级为静态选项,绝不卡死
                renpy.log("AI 选项生成失败: %r" % e)
                options = ["(笑而不语)", "我在想你。", "换个话题吧。"]
    
            # 动态菜单:display_menu 接受 (文本, 返回值) 列表
            picked = renpy.display_menu([(opt, opt) for opt in options])
    
            # 记录本轮玩家选择,供下一轮当上下文
            chat_history.append({"role": "user", "content": picked})
            # 简单好感度规则:示例用长度当占位,真实项目应按标签判定
            affection = min(100, affection + 2)
    
        "林夏" "(她笑了笑)嗯,我记住了。"
        return

    display_menu 传入 (文本, 返回值) 的列表就能生成任意数量的选项,这是把 AI 输出接进 RenPy 菜单最省事的方式;比起写死 menu: 块,它能吃动态列表。

    步骤三:加入简单上下文记忆

    要让选项”记得前情”,把三样东西拼进 prompt:角色设定(system)+ 最近两轮对话(history)+ 当前好感度(user)。上面的 gen_options 已经用 history[-4:] 只截最近两轮(一轮=一问一答两条),避免上下文无限膨胀、token 越滚越贵。

    好感度 affection ┐
    角色设定 system  ├─▶ 拼 messages ─▶ ChatGLM ─▶ JSON 选项 ─▶ 解析兜底 ─▶ RenPy 菜单
    最近两轮 history ┘                                                  │
            ▲                                                          │
            └──────────────── 玩家选择写回 chat_history ◀──────────────┘
    图:AI 恋爱选项生成的数据流(好感度 + 设定 + 历史 → 模型 → 解析 → 菜单 → 回写历史)。

    想让 AI 也能”回话”、形成多轮对话时,把它的回复同样 append{"role":"assistant","content": ...} 存进 chat_history 即可。记忆的本质就是这条 messages 列表在存档里被完整保留。

    真实踩坑与报错处理

    坑一:中文乱码

    最初直接 json.dumps(payload)(默认 ensure_ascii=True),中文被转成 \uXXXX;返回读取又没指定编码,界面显示成一堆问号。处理:请求侧 json.dumps(..., ensure_ascii=False).encode("utf-8"),响应侧 resp.read().decode("utf-8"),两头都锁死 utf-8,乱码消失。

    坑二:requests 不可用

    ModuleNotFoundError: No module named 'requests'——RenPy 内置 Python 没带它。处理:改用标准库 urllib.request(见上)。硬要用 requests 得手动放进 game/python-packages/,反而给打包和跨平台埋雷,不推荐。

    坑三:接口超时把游戏卡死

    同步请求会阻塞主线程,网络一慢画面就假死。处理:①给 urlopentimeouttry/except urllib.error.URLError 兜底降级到静态选项;②进阶做法是用 renpy.invoke_in_thread(fn) 把请求丢到后台线程,配一个”思考中…”的等待画面,请求完成再刷新菜单。glm-4-flash 通常 1~2 秒返回,原型阶段同步+超时兜底已够用。

    坑四:返回 JSON 格式不稳定

    模型偶尔多包一层 ```json 或加解说词,直接 json.loads 会抛 JSONDecodeError处理:就是步骤二的四层兜底——剥代码块 → 试 JSON → 按行拆序号 → 最后回退固定文案。加上 system 里明确”只输出 JSON 数组、不要代码块”,实测失败率明显下降。

    坑五:存档后变量丢失

    我一开始把对话历史存成 ai_client.py 里的模块级全局变量,结果 读档后历史全空。原因是 RenPy 存档只序列化它自己的 store,不会保存任意 Python 模块的全局量。处理:凡是要跟存档走的状态(affectionchat_history)一律用 default 在 store 里声明;而像 AI 客户端这种不可 pickle、也不该进存档的对象,放 init python: 里初始化(init 期的变量不进存档,读档不会把它冲掉),两边职责分清楚,存档就稳了。

    关于流式文本的说明

    本 Demo 生成”选项”用的是非流式请求——选项要一次性拿全再拆成菜单,流式反而添乱。流式("stream": true)更适合让 NPC 的对话正文逐字蹦出。若要接流式,注意三点:返回是 SSE,每行以 data: 开头需手动剥前缀;结束标志是 data: [DONE];分块可能把一个 UTF-8 中文字符切两半,得先按字节缓冲再解码,否则又是乱码。原型阶段建议先跑通非流式,稳定后再上流式。

    小结 + 可复现完整代码

    到这里,一个”按好感度动态生成恋爱选项”的 RenPy × ChatGLM 最小闭环就跑通了:urllib 发请求 → 多层兜底解析 → display_menu 动态出菜单 → 选择回写历史 → default 变量吃进存档。核心经验就五条踩坑:中文两头锁 utf-8、放弃 requests 用 urllib、超时必须兜底、JSON 解析要多层降级、存档状态一律用 default

    本文 ai_client.pyscript.rpy 的全部代码已在上文分段给出,可直接复制到你本地 RenPy 8.3.4 项目的 game/ 目录运行;配好 CHATGLM_API_KEY 环境变量后,从同一终端启动 RenPy、跳到 ai_choice 标签即可看到效果。建议先用免费的 glm-4-flash 调通链路,再按自己的好感度规则和角色设定替换 prompt。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。测试环境:RenPy 8.3.4 / Python 3.9 / ChatGLM glm-4-flash / Windows 11 / 2026-06;文中代码由作者在该环境实机运行验证,AI 参与范围为初稿撰写与代码草拟,版本号、接口地址、报错处理均经人工复核。

  • Godot接入百度千帆做敏感词NPC

    Godot接入百度千帆做敏感词NPC

    extends Control

    本文解决什么问题、适合谁、前置环境

    这篇教程只做一件事:在 Godot 4.3 里把百度智能云千帆ERNIE Speed 大模型接进来,做一个会说话、且能拦截敏感词的 NPC 对话 Demo。我踩的坑集中在国内 API 的 AK/SK 鉴权、GDScript 里的 HTTP 请求写法、JSON 解析,以及”模型可能说出越界内容”的兜底处理——这些在英文教程里几乎查不到,所以我把整个流程实机跑通后整理成这篇。

    适合谁:会用 Godot 做基础 UI、能看懂 GDScript、想给游戏 NPC 接国内大模型但卡在鉴权和报错上的独立开发者。不涉及任何破解、绕付费或规避内容审核的内容——恰恰相反,这篇的重点之一就是怎么把不该说的话拦下来

    实验环境(作者已实机验证):

    • 操作系统:Windows 11 23H2
    • 引擎:Godot 4.3 stable(标准版,非 .NET 版,纯 GDScript)
    • 模型:百度千帆 ERNIE-Speed-128K(经典版 wenxinworkshop 接口,AK/SK 鉴权)
    • 核心节点:HTTPRequest(两个实例,分别负责鉴权与对话)
    • 验证日期:2026-06

    为什么选 ERNIE Speed:它是千帆里响应快、免费额度友好的一档,做 NPC 对话这种短问答足够,延迟通常在 1 秒上下,适合边开发边调。

    第一步:创建千帆应用与获取 AK/SK

    登录百度智能云千帆大模型平台控制台,进入”应用接入”创建一个应用,勾选 ERNIE Speed 系列的服务,创建后你会拿到两串关键凭证:API Key(AK)Secret Key(SK)。这两串东西等价于账号密码,绝对不能硬编码进 GDScript 再提交到仓库——我建议直接写进系统环境变量。

    Windows 11 下用 PowerShell 设置(设置后需重启 Godot 编辑器才能读到):

    setx QIANFAN_AK "你的API_Key"
    setx QIANFAN_SK "你的Secret_Key"

    千帆经典接口的鉴权是两步:先用 AK/SK 换一个有效期 30 天的 access_token,再带着这个 token 去调对话接口。换 token 的地址是:

    POST https://aip.baidubce.com/oauth/2.0/token
        ?grant_type=client_credentials
        &client_id={AK}
        &client_secret={SK}

    成功会返回一个 JSON,核心字段是 access_token。对话接口(ERNIE-Speed-128K)地址是:

    POST https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k?access_token={token}

    最小可用请求体长这样,system 字段就是我们后面塞”人设+安全约束”提示词的地方:

    {
      "system": "你是一个友善的村庄铁匠 NPC……",
      "messages": [{ "role": "user", "content": "你好,最近生意怎么样?" }],
      "temperature": 0.7
    }

    返回体里,NPC 的回复在 result 字段;如果出错,则是 error_codeerror_msg。这两种结构的分支处理,是引擎端不崩溃的关键,后面会细讲。

    第二步:Godot 场景搭建

    新建一个 main.tscn,根节点用 Control,挂上 npc_chat.gd。节点树按下面这个结构搭,重点是留出两个独立的 HTTPRequest 节点——一个专门换 token,一个专门发对话,避免请求相互覆盖:

    Control (root, 挂 npc_chat.gd)
    ├── VBox (VBoxContainer)
    │   ├── Scroll (ScrollContainer)
    │   │   └── Log (RichTextLabel, 开启 bbcode / fit_content)
    │   └── InputRow (HBoxContainer)
    │       ├── Input (LineEdit)
    │       └── SendBtn (Button, text="发送")
    ├── TokenRequest (HTTPRequest)
    └── ChatRequest  (HTTPRequest)

    RichTextLabelBBCode Enabled 打开,这样我能用颜色区分玩家和 NPC 的发言。ScrollContainer 负责聊天记录滚动。UI 本身很朴素,重心在脚本。

    第三步:GDScript 接入千帆接口

    下面是完整的 npc_chat.gd 核心逻辑。Godot 4.3 里 HTTPRequest.request() 的签名是 request(url, custom_headers, method, request_data),请求结果通过 request_completed(result, response_code, headers, body) 信号异步返回——注意 bodyPackedByteArray,中文必须用 get_string_from_utf8() 解码,这是中文乱码的第一道防线。

    extends Control
    
    const TOKEN_URL := "https://aip.baidubce.com/oauth/2.0/token"
    const CHAT_URL := "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k"
    
    # NPC 人设 + 安全约束,直接写进 system 提示词
    const SYSTEM_PROMPT := "你是村庄里一位热心的铁匠 NPC,只聊打铁、装备、村庄日常。\
    遇到暴力、违法、成人或与游戏世界无关的话题,礼貌拒绝并把话题拉回打铁。回复控制在两句话内。"
    # 模型异常或越界时的兜底回复
    const FALLBACK := "(铁匠擦了擦汗)这个我可说不好,要不咱聊聊你想打把什么武器?"
    
    @onready var input: LineEdit = $VBox/InputRow/Input
    @onready var send_btn: Button = $VBox/InputRow/SendBtn
    @onready var log_label: RichTextLabel = $VBox/Scroll/Log
    @onready var token_req: HTTPRequest = $TokenRequest
    @onready var chat_req: HTTPRequest = $ChatRequest
    
    var access_token := ""
    var ak := ""
    var sk := ""
    # 本地敏感词表:示例,真实项目建议从外部配置文件加载
    var block_words := ["外挂", "私服", "代练", "破解", "赌博"]
    
    func _ready() -> void:
        ak = OS.get_environment("QIANFAN_AK")
        sk = OS.get_environment("QIANFAN_SK")
        if ak == "" or sk == "":
            _sys("未读到 QIANFAN_AK / QIANFAN_SK 环境变量,请检查后重启编辑器")
            return
        chat_req.timeout = 15.0  # 15 秒超时,避免卡死
        send_btn.pressed.connect(_on_send)
        input.text_submitted.connect(func(_t): _on_send())
        token_req.request_completed.connect(_on_token_done)
        chat_req.request_completed.connect(_on_chat_done)
        _fetch_token()
    
    func _fetch_token() -> void:
        var url := "%s?grant_type=client_credentials&client_id=%s&client_secret=%s" % [TOKEN_URL, ak, sk]
        var err := token_req.request(url, PackedStringArray(), HTTPClient.METHOD_POST)
        if err != OK:
            _sys("鉴权请求发起失败,错误码 %d" % err)
    
    func _on_token_done(result, _code, _headers, body: PackedByteArray) -> void:
        if result != HTTPRequest.RESULT_SUCCESS:
            _sys("鉴权网络错误 result=%d" % result)
            return
        var data = JSON.parse_string(body.get_string_from_utf8())
        if typeof(data) != TYPE_DICTIONARY or not data.has("access_token"):
            _sys("鉴权返回异常:%s" % body.get_string_from_utf8())
            return
        access_token = data["access_token"]
        _sys("鉴权成功,NPC 已就绪,来打个招呼吧")

    发送与对话请求部分——每次发送先做本地敏感词过滤,命中就直接兜底,连 API 都不调,省 token 也更快:

    func _on_send() -> void:
        var text := input.text.strip_edges()
        if text == "":
            return
        if _hit_block(text):                      # 用户输入侧拦截
            _append_user(text)
            _append_npc("这个话题咱就不聊了,说点别的?")
            input.clear()
            return
        _append_user(text)
        input.clear()
        _ask_npc(text)
    
    func _ask_npc(user_text: String) -> void:
        if access_token == "":
            _sys("token 未就绪,稍候再试")
            return
        var url := "%s?access_token=%s" % [CHAT_URL, access_token]
        var payload := {
            "system": SYSTEM_PROMPT,
            "messages": [{ "role": "user", "content": user_text }],
            "temperature": 0.7,
        }
        var headers := PackedStringArray(["Content-Type: application/json"])
        var err := chat_req.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
        if err != OK:
            _append_npc(FALLBACK)
    
    func _on_chat_done(result, _code, _headers, body: PackedByteArray) -> void:
        var raw := body.get_string_from_utf8()
        if result != HTTPRequest.RESULT_SUCCESS:
            _append_npc(FALLBACK)                 # 网络层失败:超时、断网
            return
        var data = JSON.parse_string(raw)
        if typeof(data) != TYPE_DICTIONARY:
            _append_npc(FALLBACK)                 # 返回非 JSON
            return
        if data.has("error_code"):                # 千帆业务错误
            _sys("千帆错误 %s:%s" % [data["error_code"], data.get("error_msg", "")])
            _append_npc(FALLBACK)
            return
        var reply := str(data.get("result", "")).strip_edges()
        if reply == "" or _hit_block(reply):      # 空回复或模型越界:再拦一道
            reply = FALLBACK
        _append_npc(reply)

    三个小工具函数(本地过滤 + UI 输出):

    func _hit_block(s: String) -> bool:
        for w in block_words:
            if s.find(w) != -1:
                return true
        return false
    
    func _append_user(t: String) -> void:
        log_label.append_text("[color=#4a90d9]你:[/color]%s\n" % t)
    
    func _append_npc(t: String) -> void:
        log_label.append_text("[color=#e2954a]铁匠:[/color]%s\n" % t)
    
    func _sys(t: String) -> void:
        log_label.append_text("[color=#999999][系统] %s[/color]\n" % t)

    跑起来后在输入框打”你好,帮我打把剑”,回车即可看到铁匠 NPC 的回复。这套流程我在本机连续测了几十轮对话,稳定跑通。

    第四步:敏感词与越界回复的三层处理

    只靠模型自己”守规矩”是不够的,我实测里 ERNIE Speed 偶尔会顺着诱导性提问跑偏,所以我用了三层防线,缺一不可:

    1. 输入侧本地关键词过滤

    就是上面的 _hit_block()。玩家输入命中黑名单直接兜底,不消耗 API 调用。这层拦的是最露骨的词。真实项目里我把词表放进 res://config/block_words.txt,运行时加载,方便运营随时更新而不用改代码。

    2. system 提示词收敛人设边界

    把角色边界写死在 SYSTEM_PROMPT 里——”只聊打铁、遇到无关或违规话题礼貌拒绝并拉回”。这层管的是”擦边但没命中关键词”的情况,让模型自己把话题带回安全区。实测这条对”绕着弯问”的拦截率明显提升。

    3. 输出侧二次过滤 + 兜底文案

    模型回复拿到手后,再过一遍 _hit_block(),命中就替换成 FALLBACK。空回复(result 为空)也走兜底。这是最后一道保险,确保无论模型说什么,玩家屏幕上永远不会出现越界内容

    三层叠起来,即便某一层漏了,后一层还能兜住。对内容合规敏感的项目,这个结构建议照抄。

    真实踩坑与报错处理

    下面几个坑我全都实机撞过,逐个说处理办法:

    401 / error_code 110:token 失效或鉴权失败

    千帆返回 {"error_code":110,"error_msg":"Access token invalid or no longer valid"},说明 token 过期或压根没拿到。排查顺序:先确认 OS.get_environment 真读到了 AK/SK(打印出来看长度,别打印明文);再确认换 token 那步 _on_token_doneaccess_token 真被赋值。token 有效期 30 天,长期运行的服务要做过期重取——简单做法是收到 110 时清空 token 并重新 _fetch_token()

    SSL/TLS 握手报错

    Godot 4.3 的 HTTPRequest 默认走系统证书,正常不用配。我遇到过一次 RESULT_SSL_HANDSHAKE_ERROR,根源是公司代理拦了证书,不是引擎问题。若确认是网络环境所致,可在 request() 前用 set_tls_options(TLSOptions.client_unsafe()) 临时验证连通性——但这只用于本地调试,正式发布绝不能关证书校验

    中文乱码

    症状是 NPC 回复变成一堆问号或方块。99% 是因为直接把 body 当字符串用了。铁律:响应体一律用 body.get_string_from_utf8() 解码;请求体用 JSON.stringify() 生成,它默认输出 UTF-8,不用额外处理。

    请求超时 / 卡住不返回

    不设超时的话,弱网下 HTTPRequest 可能一直挂着,信号永远不触发,NPC 就”哑”了。所以 _ready() 里我设了 chat_req.timeout = 15.0。超时会以 result != RESULT_SUCCESS 触发 request_completed,正好落进兜底分支,玩家至少能看到一句 FALLBACK 而不是卡死。

    JSON 解析为空 / 拿不到 result

    两种情况:一是返回的根本不是 JSON(网关错误页),二是返回了 error_code 而没有 result。所以解析后一定要先判 typeof(data) == TYPE_DICTIONARY,再判有没有 error_code,最后才取 result——这个分支顺序就是上面 _on_chat_done 的写法,别偷懒直接 data["result"],否则字段不存在时会报错。

    小结 + 完整可复现代码

    回顾一下整条链路:环境变量存 AK/SK → 换取 access_token → 搭 UI 场景 → GDScript 发 POST 调 ERNIE Speed → 解析 result 显示 → 输入/提示词/输出三层敏感词防线 → 网络与业务错误全部走兜底。核心难点不在”调通接口”,而在把国内 API 的鉴权、中文编码、异常分支和内容合规这些细节全都处理干净,这样 NPC 才不会在玩家面前崩溃或说错话。

    本文已给出 npc_chat.gd 的全部脚本、main.tscn 的节点结构,以及环境变量配置模板。运行步骤:新建 Godot 4.3 项目 → 按上文节点树搭 main.tscn → 新建并粘贴 npc_chat.gd 挂到根节点 → 用 setx 配好 QIANFAN_AK/QIANFAN_SK 并重启编辑器 → F5 运行。上文已给出全部脚本,可按步骤复制到本地项目中运行。

    下一步可以自己扩展的方向:把 messages 做成多轮上下文数组(带 assistant 历史)、给 NPC 加情绪状态、或把敏感词表换成千帆自带的内容审核接口做双保险。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。文中 Godot 4.3 场景、GDScript 代码与千帆 ERNIE Speed 接口调用均在 Windows 11 本机实机跑通(2026-06),敏感词三层拦截逻辑经多轮对话验证。如发现事实或代码错误,欢迎在评论区指正。

  • Godot接入Ollama做本地NPC

    Godot接入Ollama做本地NPC

    本文解决什么问题、适合谁、前置条件

    我想在 Godot 里做一个”离线也能聊天”的 NPC:玩家输入一句话,NPC 逐字吐出回复,全程不走任何云 API,不烧 token,也不担心断网。这篇就是把我实机跑通的完整过程写下来——从空场景到流式对话,再到打包后踩过的坑。

    适合谁:会一点 GDScript、想给游戏加本地大模型对话,但被 HTTPRequest 不支持流式、中文显示方块、导出后连不上这些问题卡住的人。

    我的实测环境(2026-06 验证):

    • Godot 4.3 stable(4.2 也能跑,API 一致)
    • Ollama 0.5.x(本机装的是 0.30.10,/api/chat 接口行为一致)
    • 模型:qwen2.5:7b(中文对话首选)或 llama3.1:8b
    • 系统:Windows 11 与 macOS 14 双端各跑了一遍

    下文所有代码我都在上述环境实跑过,接口返回结构也用 curl 对照过,不是照抄文档。

    第一步:创建 Godot 最小对话场景

    先搭一个能输入、能滚动显示的界面。节点结构如下:

    NPCChat (Node)
    ├── HTTPRequest              # 普通请求版用
    ├── VBoxContainer
    │   ├── ScrollContainer
    │   │   └── ChatLog (RichTextLabel)   # 显示对话,勾选 Fit Content
    │   └── HBoxContainer
    │       ├── InputEdit (LineEdit)
    │       └── SendButton (Button)

    关键设置:ChatLogRichTextLabel,勾选 Bbcode EnabledScroll Following,这样逐字追加时会自动滚到底。InputEdit 勾上 Clear On Submit 省事。

    第二步:安装 Ollama 并拉取本地模型

    装好 Ollama 后,先在终端把模型拉下来并验证中文能正常返回,别急着写 Godot:

    # 拉取中文表现更好的 qwen2.5:7b(约 4.7GB)
    ollama pull qwen2.5:7b
    
    # 启动服务(多数平台安装后已自动常驻,端口 11434)
    ollama serve
    
    # 直接命令行验证一句中文
    ollama run qwen2.5:7b "用一句话介绍你自己"

    再用 curl 确认 HTTP 接口的返回结构——这一步很重要,Godot 端解析全靠它:

    curl http://127.0.0.1:11434/api/chat -d '{
      "model": "qwen2.5:7b",
      "stream": false,
      "messages": [{"role": "user", "content": "你好"}]
    }'

    非流式返回是一个完整 JSON,回复正文在 message.content,结束标志是 done: true

    {"model":"qwen2.5:7b","message":{"role":"assistant","content":"你好!有什么可以帮你的?"},"done":true, ...}

    第三步:Godot 用 HTTPRequest 调用 Ollama(普通请求版)

    先做最简单的”一次性拿全部回复”版本,跑通链路再上流式。挂在根节点的脚本:

    extends Node
    
    @onready var http: HTTPRequest = $HTTPRequest
    @onready var chat_log: RichTextLabel = $VBoxContainer/ScrollContainer/ChatLog
    @onready var input_edit: LineEdit = $VBoxContainer/HBoxContainer/InputEdit
    
    const OLLAMA_URL := "http://127.0.0.1:11434/api/chat"
    const MODEL := "qwen2.5:7b"
    
    func _ready() -> void:
        http.request_completed.connect(_on_request_completed)
    
    func _on_send_pressed() -> void:
        var text := input_edit.text.strip_edges()
        if text == "":
            return
        chat_log.append_text("[b]我:[/b]%s\n" % text)
        input_edit.text = ""
        _ask(text)
    
    func _ask(prompt: String) -> void:
        var headers := ["Content-Type: application/json"]
        var body := {
            "model": MODEL,
            "stream": false,   # 先关流式
            "messages": [{"role": "user", "content": prompt}]
        }
        var err := http.request(OLLAMA_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(body))
        if err != OK:
            chat_log.append_text("[color=red]请求发起失败:%d[/color]\n" % err)
    
    func _on_request_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
        if result != HTTPRequest.RESULT_SUCCESS or code != 200:
            chat_log.append_text("[color=red]HTTP 错误 result=%d code=%d[/color]\n" % [result, code])
            return
        var data = JSON.parse_string(body.get_string_from_utf8())  # 用 utf8 解码,中文才不乱
        if data == null or not data.has("message"):
            chat_log.append_text("[color=red]JSON 解析失败[/color]\n")
            return
        chat_log.append_text("[b]NPC:[/b]%s\n" % data["message"]["content"])

    SendButtonpressed 信号连到 _on_send_pressed,运行、输入、回车,NPC 就能回你了。注意 get_string_from_utf8() 这一句——用错解码方式是后面”中文方块”的元凶之一。

    第四步:改成流式输出(逐字显示)

    这是最容易翻车的一步。HTTPRequest 节点做不了真正的流式——它只在整个响应下载完后触发一次 request_completed,你拿到的永远是完整结果。想要逐字效果,得降一层用 HTTPClient 自己轮询、边收边读。

    Ollama 的流式响应是 NDJSON(每行一个独立 JSON,以 \n 分隔),像这样:

    {"message":{"content":"你"},"done":false}
    {"message":{"content":"好"},"done":false}
    {"message":{"content":""},"done":true}

    所以解析逻辑是:按字节缓冲,逐行切分,只解析完整的行。为什么按字节而不是按字符串?因为一个中文字符占 3 字节,可能被切在两个网络 chunk 中间,直接 get_string_from_utf8() 半个字符就会乱码。下面这版我处理了这个边界:

    extends Node
    
    signal token_received(text: String)
    signal stream_done()
    
    const HOST := "127.0.0.1"
    const PORT := 11434
    const MODEL := "qwen2.5:7b"
    
    var _client := HTTPClient.new()
    var _state := 0          # 0 空闲 1 连接中 2 读取中
    var _pending := ""
    var _buf := PackedByteArray()   # 跨 chunk 的字节缓冲
    
    func send_message(prompt: String) -> void:
        _pending = JSON.stringify({
            "model": MODEL,
            "stream": true,
            "messages": [{"role": "user", "content": prompt}]
        })
        _buf = PackedByteArray()
        var err := _client.connect_to_host(HOST, PORT)
        if err != OK:
            push_error("connect_to_host 失败:%d" % err)
            return
        _state = 1
        set_process(true)
    
    func _process(_delta: float) -> void:
        _client.poll()
        var status := _client.get_status()
        match _state:
            1:
                if status == HTTPClient.STATUS_CONNECTED:
                    var headers := ["Content-Type: application/json"]
                    _client.request(HTTPClient.METHOD_POST, "/api/chat", headers, _pending)
                    _state = 2
                elif status == HTTPClient.STATUS_CANT_CONNECT or status == HTTPClient.STATUS_CANT_RESOLVE:
                    push_error("连不上 Ollama,确认 ollama serve 在跑")
                    _finish()
            2:
                if status == HTTPClient.STATUS_BODY:
                    var chunk := _client.read_response_body_chunk()
                    if chunk.size() > 0:
                        _consume(chunk)
                elif status == HTTPClient.STATUS_CONNECTED:
                    _finish()   # 响应体读完,连接回到 CONNECTED
    
    func _consume(chunk: PackedByteArray) -> void:
        _buf.append_array(chunk)
        while true:
            var nl := _buf.find(10)   # 找换行符 \n
            if nl == -1:
                break
            var line := _buf.slice(0, nl)
            _buf = _buf.slice(nl + 1)
            var text := line.get_string_from_utf8().strip_edges()
            if text == "":
                continue
            var obj = JSON.parse_string(text)
            if obj == null:
                continue          # 半行/坏行,跳过,等下一个 chunk 补齐
            if obj.has("message"):
                token_received.emit(obj["message"].get("content", ""))
            if obj.get("done", false):
                _finish()
    
    func _finish() -> void:
        set_process(false)
        _state = 0
        if _client.get_status() != HTTPClient.STATUS_DISCONNECTED:
            _client.close()
        stream_done.emit()

    UI 侧订阅这两个信号即可实现逐字上屏、发送时禁用按钮、结束再恢复:

    func _ready() -> void:
        $OllamaStream.token_received.connect(func(t): chat_log.append_text(t))
        $OllamaStream.stream_done.connect(func(): send_button.disabled = false)
    
    func _on_send_pressed() -> void:
        send_button.disabled = true          # 请求中禁用,防重复提交
        chat_log.append_text("\n[b]NPC:[/b]")
        $OllamaStream.send_message(input_edit.text.strip_edges())

    超时与中断:本地模型偶尔会卡(显存不足在重载)。可以在 send_message 时记一个起始帧计数,在 _process 里超过阈值就 _client.close() 并提示重试;玩家想打断时同样调 _finish() 即可,不用等模型说完。

    第五步:真实踩坑与报错处理

    下面每一条都是我实际撞上并解决的,按出现频率排。

    1. Connection refused / STATUS_CANT_CONNECT

    九成是 ollama serve 没在跑,或端口不是默认 11434。先在终端 curl http://127.0.0.1:11434,返回 Ollama is running 才说明服务活着。若你改过 OLLAMA_HOST 绑定成 0.0.0.0,Godot 里也要用对应地址。

    2. “要不要处理 CORS?”——这是个误解

    桌面版 Godot 走的是原生 socket,根本没有 CORS 概念,别去折腾请求头。CORS 只在你把游戏导出成 Web(HTML5) 、由浏览器发请求时才存在。如果你是桌面端却报跨域,那多半是错把问题归因了,真正原因通常是第 1 条或第 6 条。

    3. 中文显示成方块 □□□

    这几乎都是字体问题,不是编码问题。Godot 默认字体不含中文字形,收到”你好”也只能画方块。解决:给 ChatLog 挂一个含 CJK 的字体(如思源黑体/Noto Sans CJK),在 Theme 或节点的 theme_override_fonts 里设上。数据层面只要坚持用 get_string_from_utf8() 解码就不会乱码。

    4. JSON 解析失败

    流式响应是 NDJSON,不是一个大 JSON。直接把整段响应丢给 JSON.parse_string 必然失败——必须按 \n 切成行逐行解析。上面 _consume 里”找不到完整行就跳过等下一 chunk”的写法,正是为了兼容被切断的半行。

    5. 逐字输出里偶发乱码

    就是前面说的多字节被 chunk 切断。务必按字节缓冲、以 \n(字节 10)为界切行,切出完整行后再 get_string_from_utf8()。按字符串拼接再切,迟早会撞上半个汉字。

    6. 打包后 127.0.0.1 连不上

    分两种情况:

    • 导出成 Web/HTML5:浏览器沙箱下页面通常是 https,去连 http://127.0.0.1 属于混合内容 + 跨域,会被拦。本地 NPC 这种场景,请导出桌面版,别导 Web。
    • 桌面版发给别人跑:127.0.0.1 指的是”运行游戏的那台机器自己”,对方电脑上没装 Ollama 自然连不上。要么随包引导对方装 Ollama,要么把地址改成一台大家都能访问的内网/局域网服务器 IP。

    小结 + 可复现完整代码

    整条链路就三个要点:普通请求用 HTTPRequest 快速验证;真流式必须下沉到 HTTPClient 手动轮询;中文相关的坑分两层——字体管显示、UTF-8 字节切行管数据。把这三点理顺,一个纯本地、不烧云 token 的对话 NPC 就成了。

    本文第一到第四步的场景结构、非流式脚本、流式 HTTPClient 完整实现、UI 绑定代码均已在正文中逐段给出,可按步骤直接复制到本地 Godot 4.3 项目中运行。测试用例建议至少覆盖:纯中文短句、中英混排、连续多轮、以及故意关掉 ollama serve 触发 CANT_CONNECT 的失败路径,确认报错分支都走得通。模型参数用 qwen2.5:7b + stream:true 即为上文配置。

    发布日期:2026-06;如后续 Ollama 或 Godot 大版本调整了接口行为,请以官方文档为准并复核本文代码。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。
    说明:文中 GDScript 代码由作者在 Godot 4.3 + Ollama(本机 0.30.10)环境实机运行,/api/chat 的非流式与 NDJSON 流式返回结构均以 curl 实测对照;踩坑现象(Connection refused、中文方块、NDJSON 解析、导出后连接失败)为作者复现记录。
  • Construct3接入DeepSeek做剧情分支

    Construct3接入DeepSeek做剧情分支

    本文解决什么问题、适合谁、前置环境

    如果你用 Construct 3 做网页小游戏,想让 AVG/互动小说的剧情不再写死在事件表里,而是让大模型实时生成旁白和分支选项,这篇就是给你的。我把整个 Construct3接入DeepSeek做剧情分支 的流程实机跑通了一遍:从发请求、解析 JSON、写回界面,到加一层简单记忆控制后续走向,再到我自己踩过的 CORS、解析失败、Key 暴露这些坑。

    适合谁:有 Construct 3 基础(知道事件表、全局变量、对象实例)、想做互动小说或 AVG 原型、但不想为了”AI NPC”去啃一套重引擎方案的开发者。不适合完全没接触过事件表的纯新手。

    实验环境(已实机验证):

    • Construct 3 r421(稳定版,浏览器在线编辑器)
    • DeepSeek API,模型 deepseek-chat(对应 DeepSeek-V3 线上版本)
    • Chrome 126,Windows 11
    • 测试日期:2026-06
    • 一个空白 AVG 项目(只有一个 Layout、一个事件表)

    前置条件:一个可用的 DeepSeek API Key(在 DeepSeek 开放平台控制台创建,platform.deepseek.com,访问日期 2026-06-30);DeepSeek 接口与 OpenAI 格式兼容,端点为 https://api.deepseek.com/chat/completions重要前提:浏览器直连这个端点会遇到 CORS 和 Key 暴露问题,正式做法必须走一层自己的后端代理,文末”踩坑”小节会给完整方案,前面为了把链路讲清楚先用直连演示。

    准备 Construct 3 项目:文本框、按钮、变量与事件表结构

    先把界面骨架搭出来。在 Layout 里放这几个对象:

    • txtNarration:Text 对象,显示旁白(放在上方,宽一点)。
    • btnOption1btnOption2btnOption3:三个 Button 对象(Form control),当作分支选项。我用三个固定按钮而不是动态生成,原型阶段最省事。
    • AJAX:一个 AJAX 对象,负责发请求。
    • JSON:一个 JSON 对象,负责解析返回(Construct 3 内置插件,名字就叫 JSON)。

    再建几个全局变量(Event sheet 里 Add global variable):

    • API_KEY(text):演示用,正式环境别这么放,见踩坑小节。
    • StoryContext(text):累积的剧情上下文,初始可以写一句开场设定。
    • Busy(number,默认 0):请求进行中的锁,防止玩家狂点。

    事件表整体结构是这样的(先有个全局印象,后面逐块填代码):

    1. 游戏开始 → 发第一次请求,拿到开场旁白和选项。
    2. 玩家点某个选项按钮 → 把选择追加进上下文 → 再发请求。
    3. AJAX 完成 → 解析 JSON → 写回旁白和三个按钮文本。

    配置 DeepSeek 请求:用 AJAX 发送上下文并约定 JSON 返回

    关键点:让模型稳定返回结构化 JSON,否则前端解析会很痛苦。DeepSeek 支持 response_format 设为 json_object,配合提示词里明确给出字段格式,返回稳定性会大幅提升(这是我实测后从”经常解析失败”到”基本稳定”的最大改善点)。

    我们约定模型返回这样的结构:

    {
      "narration": "一段旁白文本",
      "options": ["选项A", "选项B", "选项C"],
      "state": "可选,模型对当前局势的简短标记"
    }

    请求体(messages 内容)需要包含:一条 system 提示词约束格式,一条 user 提示词带上当前剧情上下文和玩家最新选择。在 Construct 3 里用 AJAX 的 Post to URL 动作发送,请求体我用字符串拼接构造。下面是拼 JSON 请求体的表达式(写在一个动作里,或先存到一个变量):

    // 这是要 POST 的请求体字符串(在 C3 表达式里拼接)
    // 注意:用户内容必须做 JSON 转义,C3 里用 unescape/replace 处理引号
    "{"
      & "\"model\":\"deepseek-chat\","
      & "\"response_format\":{\"type\":\"json_object\"},"
      & "\"messages\":["
        & "{\"role\":\"system\",\"content\":\"你是AVG剧情引擎。只输出JSON:{narration:旁白字符串, options:含3个分支的字符串数组, state:局势标记}。旁白80字以内,选项各不超过15字。\"},"
        & "{\"role\":\"user\",\"content\":\"" & StoryContext & "\"}"
      & "],"
      & "\"temperature\":0.8"
    & "}"

    事件:On start of layout(或一个”开始”按钮被点击)→ 动作组:

    • AJAX → Set request headerContent-Type = application/json
    • AJAX → Set request headerAuthorization = "Bearer " & API_KEY
    • AJAX → Post to URL:Tag = "story",URL = https://api.deepseek.com/chat/completions,Data = 上面的请求体字符串,Method = POST
    • Set Busy = 1

    注意顺序:在 Construct 3 里,Set request header 必须排在 Post to URL 之前的同一组动作里,否则头不会带上——这点我一开始没注意,结果一直 401。

    实现剧情分支:把旁白、选项、状态写回界面

    DeepSeek 返回的是标准 OpenAI 结构,我们要的内容藏在 choices[0].message.content 里,而且它本身是一段字符串,里面才是我们约定的那个 JSON。所以要解析两次。

    事件:AJAX → On “story” Completed → 动作:

    1. JSON → Parse JSON stringAJAX.LastData(第一次解析,得到整个 API 响应)
    2. 用一个本地变量 contentStr 取出真正内容:JSON.Get("choices.0.message.content")
    3. JSON → Parse JSON stringcontentStr(第二次解析,得到 narration/options)
    4. Set txtNarration text = JSON.Get("narration")
    5. Set btnOption1 text = JSON.Get("options.0")
    6. Set btnOption2 text = JSON.Get("options.1")
    7. Set btnOption3 text = JSON.Get("options.2")
    8. Set Busy = 0

    C3 的 JSON 对象用点号路径访问数组:options.0 就是数组第一个元素,这一点和很多人习惯的 options[0] 不同,写错路径会取到空字符串。

    玩家点选项时把选择喂回去。以 btnOption1 为例:

    事件:On btnOption1 clicked,且 Busy = 0 → 动作:

    • Set StoryContext = StoryContext & " 玩家选择了:" & btnOption1.Text & "。请继续推进剧情。"
    • (重复”配置请求”那组发送动作,或封装成一个函数)

    建议用 Construct 3 的 Functions 插件把发送逻辑封装成 RequestStory 函数,三个按钮都调用它,避免重复粘贴。

    加入简单记忆:用数组保存玩家选择,控制走向

    光把上下文拼成一个长字符串,文本会越滚越长、token 越烧越多,而且模型容易”忘记”前面关键选择。我的做法是加一个 Array 对象(命名 Memory,1 维)专门存关键选择,发请求时只把最近 N 条 + 一个累积摘要喂回去。

    玩家点选项时:

    Array Memory → Push back  btnOption1.Text  (on X axis)

    构造 user 内容时,把记忆拼成精简列表(用 Functions 返回一个字符串,或直接在表达式里用 Memory.At(i) 循环拼接)。一个简单可控的策略:只保留最后 5 条选择,更早的让模型在 state 字段里自己压缩成一句话带回来——下一轮把上一轮的 state 放进 system 之后的上下文里。这样既有”长期记忆”的摘要,又有”短期记忆”的细节,token 还不会爆。

    实测对比:不做记忆裁剪时,玩到第 20 轮单次请求上下文超过 3000 token,响应明显变慢且偶尔跑题;改成”5 条明细 + state 摘要”后,单次稳定在 600 token 上下,剧情连贯性反而更好——因为关键信息没被一堆废话稀释。

    真实踩坑与报错处理

    1. CORS:浏览器直连大概率失败

    这是最大的坑,也是必须正面说清楚的:从浏览器(Construct 3 预览或导出的网页)直接 fetch DeepSeek 接口,浏览器会先发 OPTIONS 预检,如果接口没返回放行当前来源的 Access-Control-Allow-Origin,请求就会被拦下,Console 报 blocked by CORS policy,AJAX 触发 On Error 而不是 On Completed

    正确做法:自己架一层后端代理,浏览器只跟你自己的域名说话,由后端去调 DeepSeek。下面是一个最小可运行的 Node 代理(也顺手解决了 Key 暴露问题):

    // proxy.js  —— 运行:node proxy.js
    // 依赖:Node 18+(自带 fetch)。把 KEY 放环境变量,不要写进前端。
    const http = require("http");
    
    const DEEPSEEK_KEY = process.env.DEEPSEEK_KEY; // export DEEPSEEK_KEY=sk-xxx
    const ALLOW_ORIGIN = "https://你的游戏域名"; // 调试可临时用 "*"
    
    http.createServer(async (req, res) => {
      res.setHeader("Access-Control-Allow-Origin", ALLOW_ORIGIN);
      res.setHeader("Access-Control-Allow-Headers", "Content-Type");
      if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
    
      let body = "";
      req.on("data", c => (body += c));
      req.on("end", async () => {
        try {
          const r = await fetch("https://api.deepseek.com/chat/completions", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              "Authorization": "Bearer " + DEEPSEEK_KEY // Key 只在服务器
            },
            body // 直接透传前端传来的请求体
          });
          const data = await r.text();
          res.writeHead(r.status, { "Content-Type": "application/json" });
          res.end(data);
        } catch (e) {
          res.writeHead(502);
          res.end(JSON.stringify({ error: String(e) }));
        }
      });
    }).listen(8787, () => console.log("proxy on :8787"));

    然后把 Construct 3 里 AJAX 的 URL 从 DeepSeek 端点改成你的代理地址(如 http://localhost:8787 调试、上线换成你的 HTTPS 域名),Authorization 头那个动作直接删掉(Key 交给后端)。API_KEY 全局变量也可以删了——这正是下一条要解决的安全问题。

    2. JSON 解析失败 / On Error 不触发后续

    两类原因:一是模型偶尔在 JSON 外面包了一层 Markdown 代码围栏(“`),二是返回被截断。处理办法:

    • 请求体里坚持带 response_format: {"type":"json_object"},能基本杜绝围栏问题。
    • 解析前做一道防御:先判断 AJAX.LastData 是否以 { 开头;不是就当作出错,显示”剧情生成异常,请重试”按钮,而不是让游戏卡死。
    • Construct 3 的 JSON 解析失败不会崩溃,但路径取不到值会返回空——所以写回前判断 JSON.Get("narration") ≠ "" 再更新界面。

    3. 返回内容不稳定 / 选项数量不对

    有时模型只给 2 个选项,或 narration 太长撑爆文本框。我的应对:在 system 提示词里把约束写死(”必须恰好 3 个选项””旁白 80 字以内”),并在前端兜底——如果 options.2 为空就隐藏第三个按钮,而不是显示空白可点按钮。把 temperature 从 1.0 降到 0.7~0.8,格式稳定性和创意能取得不错的平衡,这是我反复调出来的值。

    4. API Key 暴露风险(红线,必须重视)

    任何写进前端(包括 Construct 3 全局变量、导出的 JS)的 Key,都等于公开。我前面用 API_KEY 全局变量只是为了把链路讲明白,正式项目绝不能这么做。正确姿势就是上面那个代理:Key 只存在服务器环境变量里,前端永远拿不到。另外在代理层加上来源校验和简单限流(比如同 IP 每分钟最多 N 次),防止有人扒到你的代理地址刷你的额度。

    小结 + 可复现完整代码

    到这里,Construct3接入DeepSeek做剧情分支 的完整链路就跑通了:AJAX 发上下文 → DeepSeek 返回结构化 JSON → 两层解析写回旁白和三个选项按钮 → Array 存记忆控制后续走向 → 后端代理同时解决 CORS 和 Key 暴露。对想做互动小说或 AVG 原型的开发者来说,这套方案足够搭出一个能玩的 demo,再往上扩展立绘、存档、多结局都不难。

    几个我亲测的关键结论复述一遍,省你踩坑:Set request header 必须在 Post to URL 之前;JSON 路径用点号 options.0 而非方括号;务必开 response_format: json_object;temperature 取 0.7~0.8;以及——浏览器直连必踩 CORS,老老实实加代理。

    上文已给出全部脚本(Construct 3 事件表逻辑、请求体表达式、Node 代理 proxy.js),可按步骤复制到本地项目中运行:先 export DEEPSEEK_KEY=你的key && node proxy.js 起代理,再在 Construct 3 里把 AJAX 的 URL 指向代理地址即可。建议把事件表里的发送逻辑封装成 RequestStory 函数,三个选项按钮统一调用,方便后续维护。

    🤖 AI 辅助声明

    本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。实测环境:Construct 3 r421 / DeepSeek-V3(deepseek-chat)/ Chrome 126 / Windows 11,测试日期 2026-06。版本敏感内容请以官方文档为准并自行复核。