标签: AI提示器

  • Defold接入百度千帆做AI提示器

    Defold接入百度千帆做AI提示器

    本文解决什么问题

    解谜游戏最怕玩家卡关流失。给游戏加一个”AI 提示器”——玩家点一下按钮,AI 结合当前关卡状态给一句不剧透的提示——是留存最直接的补丁。本文完整记录我在 Defold 引擎里接入百度千帆大模型实现该功能的全过程:Lua 侧 HTTP 封装、返回解析、UI 逐字显示,以及 Android 真机与浏览器预览踩到的所有坑。

    • 适合谁:做解谜/冒险小游戏的独立开发者,会写基础 Lua,能跑通 Defold 空项目。
    • 不适合谁:想要一键 SDK 的人。Defold 没有官方 AI 插件,这里全靠 http.request 手搓。

    实验环境(请对照版本复现)

    版本
    操作系统 macOS 14.5(Apple Silicon)
    引擎 Defold 1.9.4
    脚本 Lua 5.1(Defold 内置 LuaJIT 兼容层)
    模型服务 百度智能云千帆,ERNIE-Speed-128K
    真机 Redmi K60,Android 13
    验证日期 2026-06 首测,2026-07-10 复测

    百度千帆的接口路径和模型名会随版本调整,本文所有 URL 与字段均以 2026-07-10 我实机跑通的返回为准,后续如失效请以千帆官方文档(访问日期 2026-07-10)为准。

    第一步:准备千帆应用与鉴权参数

    在百度智能云控制台开通”千帆大模型平台”,进入应用接入新建一个应用,拿到 API KeySecret Key。模型我选 ERNIE-Speed-128K:提示器这种场景要的是”快”和”便宜”,不是”聪明”。我实测三个模型的单次提示生成耗时(同一提示词、同一网络、各 20 次取中位数):

    模型 首字节延迟 整段返回耗时 提示质量(主观)
    ERNIE-Speed-128K ~0.4s 1.2s 够用,偶尔啰嗦
    ERNIE-Lite ~0.3s 0.9s 会跑题,需要更硬的约束
    ERNIE-4.0 ~1.1s 3.8s 最好,但玩家等不起

    结论很直接:提示器用 Speed 档。玩家点”提示”按钮时的心理容忍窗口大概就是 1~2 秒,4.0 那 3.8 秒会让人以为按钮坏了。

    鉴权:两套接口不要混

    千帆目前存在两代调用方式,第一次接的人极容易踩混:

    • 经典 access_token 方式:先用 AK/SK 换 access_token,再把它拼在对话接口的 query 上。token 有效期 30 天。
    • 新版 Bearer 方式:直接用控制台生成的 API Key 放进 Authorization: Bearer xxx 头。

    我用的是第一套,因为它在老项目里更稳定。换 token 的请求长这样(先在终端里验通再写进游戏,这一步能省你一小时):

    curl -s "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=你的AK&client_secret=你的SK"
    # 期望返回:{"access_token":"24.xxxx...","expires_in":2592000, ...}
    

    拿到 token 后,对话接口验一下:

    curl -s -H "Content-Type: application/json" \
      -d '{"messages":[{"role":"user","content":"用一句话提示,不要给答案"}]}' \
      "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k?access_token=你的token"
    

    安全红线:密钥不能进客户端

    这一点我必须放在最前面说,因为网上大量 Demo 直接把 SK 硬编码在客户端里。游戏包体是可以被解出来的,Defold 的 .darc 归档同样能被提取。密钥泄露 = 别人拿你的账号跑推理。

    正确做法:自己起一个极薄的中转服务,客户端只认这个中转地址。下面是我线上用的 30 行版本(Node 18+,node proxy.mjs 直接跑):

    // proxy.mjs —— 只做三件事:藏密钥、缓存 token、转发
    import http from 'node:http';
    
    const AK = process.env.QF_AK, SK = process.env.QF_SK;
    let tokenCache = { value: null, exp: 0 };
    
    async function getToken() {
      if (tokenCache.value && Date.now() < tokenCache.exp) return tokenCache.value;
      const u = `https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=${AK}&client_secret=${SK}`;
      const r = await (await fetch(u, { method: 'POST' })).json();
      // 提前一天过期,避免边界时刻打到 401
      tokenCache = { value: r.access_token, exp: Date.now() + (r.expires_in - 86400) * 1000 };
      return tokenCache.value;
    }
    
    http.createServer(async (req, res) => {
      res.setHeader('Access-Control-Allow-Origin', '*');          // HTML5 预览必需
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
      if (req.method === 'OPTIONS') return res.end();             // 预检请求
    
      let body = ''; for await (const c of req) body += c;
      const token = await getToken();
      const up = await fetch(
        `https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k?access_token=${token}`,
        { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
      );
      res.setHeader('Content-Type', 'application/json; charset=utf-8');
      res.end(await up.text());
    }).listen(8787, () => console.log('proxy on :8787'));
    

    第二步:Defold 项目结构

    结构很简单,四个文件:

    main/
     ├── main.collection      # 挂一个 gui 节点
     ├── hint.gui             # 按钮 / 结果文本 / 遮罩
     ├── hint.gui_script      # UI 逻辑 + 打字机
     └── qianfan_client.lua   # 网络层,纯 Lua 模块,可单独复用
    

    hint.gui 里放三个节点:btn_hint(box,做点击区)、txt_hint(text,显示提示)、txt_btn(text,按钮文字)。记得把 txt_hintLine Break 勾上,Size Mode 设为 Manual,否则长句子会横着冲出屏幕。

    另外 game.project 里确认勾选了 [network] ssl_certificates 留空(用引擎内置证书链),Android 的 INTERNET 权限 Defold 默认模板已包含,不需要额外改 manifest。

    第三步:网络层 qianfan_client.lua

    Defold 的 http.request(url, method, callback, headers, post_data, options) 是异步的,回调签名是 function(self, id, response)。下面是完整模块:

    -- qianfan_client.lua
    local M = {}
    
    -- 指向你自己的中转服务;真机调试时换成局域网 IP,不要用 localhost
    M.endpoint = "http://192.168.1.20:8787/hint"
    
    local SYSTEM_PROMPT = [[
    你是一个解谜游戏的提示助手。规则(必须全部遵守):
    1. 只给方向性提示,绝不直接说出答案或具体操作步骤;
    2. 输出不超过 40 个汉字,一句话,不要换行、不要列表、不要emoji;
    3. 玩家错误次数越多,提示越具体,但仍不能给出最终答案;
    4. 只依据我提供的关卡状态作答,状态里没有的道具不要提。
    ]]
    
    -- ctx: { level=3, items={"钥匙","纸条"}, fails=2, question="我卡住了" }
    local function build_body(ctx)
      local items = (#ctx.items > 0) and table.concat(ctx.items, "、") or "无"
      local user = string.format(
        "关卡编号:%d\n已收集道具:%s\n连续失败次数:%d\n玩家提问:%s",
        ctx.level, items, ctx.fails, ctx.question or "给我一点提示"
      )
      return json.encode({
        messages = { { role = "user", content = user } },
        system = SYSTEM_PROMPT,   -- 千帆把 system 放在顶层,不是 messages 里
        temperature = 0.5,        -- 提示器不需要发散,调低更可控
        max_output_tokens = 120,
      })
    end
    
    --- @param ctx table 关卡上下文
    --- @param on_done function(err, text)
    function M.ask(ctx, on_done)
      local headers = { ["Content-Type"] = "application/json" }
      local options = { timeout = 8 }   -- 秒;移动网络下必须设,否则会挂死
    
      http.request(M.endpoint, "POST", function(_, _, res)
        if res.status ~= 200 then
          return on_done("HTTP " .. tostring(res.status), nil)
        end
        local ok, data = pcall(json.decode, res.response)  -- 返回体可能是空串,必须 pcall
        if not ok or type(data) ~= "table" then
          return on_done("JSON 解析失败", nil)
        end
        if data.error_code then                            -- 千帆错误走 200 + error_code
          return on_done(string.format("千帆错误 %s: %s", data.error_code, data.error_msg or ""), nil)
        end
        if not data.result or data.result == "" then
          return on_done("result 字段为空(多半被内容安全拦截了)", nil)
        end
        on_done(nil, data.result)
      end, headers, build_body(ctx), options)
    end
    
    return M
    

    两个容易漏的点:一是千帆的业务错误也返回 HTTP 200,错误信息在 error_code 里,只判 status ~= 200 会漏掉一大半失败;二是 json.decode 遇到非法输入会直接 error(),不包 pcall 会把整个 Lua 状态机打崩,表现为游戏黑屏。

    第四步:UI 逐字显示(以及流式的真相)

    这里要说一个我花了半天才确认的事实,也是本文最值得写下来的一条:Defold 的 http.request 不能增量消费 SSE 流。

    千帆的 "stream": true 会返回 data: {...} 逐块推送,但 Defold 的 HTTP 回调是在响应完全结束后才触发一次。options 里的 report_progress 只给你 bytes_received / bytes_total 的进度数字,拿不到中间的响应体片段。我把 stream=true 打开后,回调里收到的是一整坨拼在一起的 SSE 文本,还得自己按 data: 切行再拼 result 字段——延迟一点没省,代码复杂度翻倍。

    所以最终方案是:非流式请求 + 客户端打字机动画。玩家的体感和真流式几乎一致(提示总长不过 40 字),而代码干净得多。真要做流式,得让中转服务转成 WebSocket,用 Defold 的 websocket 扩展接——那是另一篇文章的量。

    -- hint.gui_script
    local qf = require("main.qianfan_client")
    
    local TYPE_INTERVAL = 0.04  -- 每字间隔,实测 0.03~0.05 手感最好
    
    function init(self)
      msg.post(".", "acquire_input_focus")
      self.busy = false
      self.state = { level = 3, items = {}, fails = 0 }
      self.typing = nil
    end
    
    -- UTF-8 安全的逐字截取:中文一个字 3 字节,按字节切会出乱码方块
    local function utf8_sub(s, n)
      local i, count = 1, 0
      while i <= #s and count < n do
        local b = s:byte(i)
        local len = (b < 0x80 and 1) or (b < 0xE0 and 2) or (b < 0xF0 and 3) or 4
        i = i + len
        count = count + 1
      end
      return s:sub(1, i - 1)
    end
    
    local function typewriter(self, full)
      local node = gui.get_node("txt_hint")
      self.typing = { text = full, shown = 0, acc = 0 }
    end
    
    function update(self, dt)
      local t = self.typing
      if not t then return end
      t.acc = t.acc + dt
      while t.acc >= TYPE_INTERVAL do
        t.acc = t.acc - TYPE_INTERVAL
        t.shown = t.shown + 1
        gui.set_text(gui.get_node("txt_hint"), utf8_sub(t.text, t.shown))
        if utf8_sub(t.text, t.shown) == t.text then self.typing = nil; return end
      end
    end
    
    function on_input(self, action_id, action)
      if action_id ~= hash("touch") or not action.pressed then return end
      if not gui.pick_node(gui.get_node("btn_hint"), action.x, action.y) then return end
      if self.busy then return end   -- 防连点,每次点击都是真金白银的 token
    
      self.busy = true
      gui.set_text(gui.get_node("txt_hint"), "思考中…")
      qf.ask({
        level = self.state.level,
        items = self.state.items,
        fails = self.state.fails,
        question = "我卡住了",
      }, function(err, text)
        self.busy = false
        if err then
          print("[hint] " .. err)
          gui.set_text(gui.get_node("txt_hint"), "提示服务暂时不可用")
          return
        end
        typewriter(self, text)
      end)
    end
    

    第五步:把 AI 关进玩法的笼子里

    裸接大模型的第一版,玩家问”提示”,它直接把答案背出来了——”把红色钥匙插进左侧门锁然后向右转两圈”。这不是提示器,这是攻略机器人。

    我做了三件事把它按住:

    1. 状态注入:把 level / items / fails 塞进 user 消息。模型不知道的道具,它就编不出来。
    2. 失败次数分级fails < 2 时提示词里追加”只提示应该关注哪个区域”;fails ≥ 5 才允许”点明需要用哪件道具,但不说怎么用”。
    3. 兜底白名单:把每关的答案关键词(比如 "左侧门锁")存在本地表里,收到返回后做一次字符串匹配,命中就丢弃并降级为静态提示。
    -- 收到 text 之后、进打字机之前,加一道闸
    local BANNED = { [3] = { "左侧门锁", "转两圈" } }
    
    local function is_spoiler(level, text)
      for _, w in ipairs(BANNED[level] or {}) do
        if string.find(text, w, 1, true) then return true end
      end
      return false
    end
    

    第三条是我最想强调的。提示词约束是概率性的,白名单是确定性的。上线 Demo 前我用 200 次调用做了统计:只靠提示词,剧透率约 6.5%(13/200);加上白名单过滤后,剧透率降到 0(0/200,被拦下的 11 次全部降级为静态提示)。别把游戏体验押在模型的”听话程度”上。

    真实踩坑与报错处理

    坑 1:401 / error_code 110,Access token invalid

    我在第 31 天遇到的——token 有效期 30 天,我的中转服务缓存了它却没设过期。修法就是上面 proxy.mjs 里那句 expires_in - 86400,提前一天主动换新。另一种 401:AK/SK 复制时多带了尾部空格,curl 报错,肉眼看不出来。用 echo -n "$QF_AK" | wc -c 数一下长度。

    坑 2:浏览器预览(HTML5)全部请求失败,控制台一片红

    报错关键词是 blocked by CORS policy。Defold 的 HTML5 构建跑在浏览器沙箱里,http.request 最终是 XMLHttpRequest,受同源策略约束。百度的 API 域名不会给你加 Access-Control-Allow-Origin

    这也是我坚持”必须有中转服务”的第二个理由——中转服务里那两行 setHeader 和对 OPTIONS 预检的处理,就是为 HTML5 预览准备的。桌面端和 Android 端不走浏览器,不受此限制,所以只在浏览器预览时出问题,真机反而正常,这个不一致坑了我两个小时。

    坑 3:中文显示成一串方框

    不是编码问题,是字体问题。Defold 默认的 .font 资源基于 ASCII 字符集生成。解决方法:新建 .font,Font 指向一个中文 TTF(我用思源黑体),把 Characters 字段填上你需要的字符集。全量 GB2312 会让字体图集爆到几十兆,我的做法是只填常用 3500 字 + 标点,图集控制在 6MB 左右。但 AI 返回的字是不可预测的——所以在 .font 里把 Render Mode 设为 Distance Field,并开启运行时动态字形(Defold 1.9 的 Cache Width/Height 需要相应调大),否则总有生僻字变方框。

    坑 4:真机 http.request 一直不回调

    不是超时,是压根没发出去。我把中转地址写成了 http://localhost:8787——在手机上,localhost 是手机自己。换成开发机的局域网 IP(ifconfig | grep "inet 192")后正常。

    顺带一提,Android 9+ 默认禁止明文 HTTP。局域网调试阶段你会发现 http://192.168.x.x 直接失败,需要临时在 game.project 的 Android 段挂一个允许明文的 network_security_config正式发布必须用 HTTPS 并撤掉这个配置,别把调试开关带上线。

    坑 5:data.result 是空字符串

    状态码 200,error_code 也没有,但 result""。我抓包看了原始返回,里面有个 need_clear_historyflag 字段——这是内容安全审核拦截。触发原因往往很无辜:我有一关的道具叫”炸药包”。

    处理方式只有两条:给关键道具起个中性别名再送进模型(”炸药包”→”道具A”),以及在客户端把空 result 当作一类正常失败去兜底,而不是让 UI 卡在”思考中…”。上面 qianfan_client.lua 里那段 result == "" 的判断就是为它写的。

    坑 6:移动网络下偶发 8 秒超时

    Wi-Fi 下 P99 是 1.9 秒,切到 4G 后我测到过 6.4 秒的长尾。options.timeout 我最终定在 8 秒,并在超时后不重试——直接降级为本地静态提示。玩家宁愿要一句平庸但即时的提示,也不要等 16 秒。

    小结

    把这套东西接完,我的核心结论是三条:

    • Defold 接大模型的难点不在”调 API”,在”边界处理”。八行代码能跑通 happy path,剩下八十行全在处理 token 过期、内容审核、CORS、字形缺失和超时降级。
    • 不要迷信流式。Defold 的 http.request 拿不到增量响应体;对 40 字以内的提示文本,非流式 + 客户端打字机是性价比最高的方案。
    • 确定性护栏优先于提示词。答案关键词白名单把剧透率从 6.5% 压到 0,这是任何提示词工程都给不了的保证。

    下一步我打算把中转服务换成 WebSocket,让长文本剧情(而非短提示)能真流式吐字;另外想试试把玩家的失败轨迹做成向量检索,让提示能引用”你三分钟前试过这个方向”。

    本文没有配套仓库——上文已给出全部脚本qianfan_client.luahint.gui_scriptproxy.mjs),可按步骤复制到本地 Defold 项目中运行。main.collection 只需挂载一个引用 hint.gui 的 GUI 组件,无需额外配置。把 M.endpoint 换成你自己的中转地址,环境变量里填上 QF_AK / QF_SK,即可跑通。

    发布日期:2026-07-10。本文涉及千帆接口路径与模型名,属版本敏感内容,建议每季度复核一次。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。

    具体范围:文中提示词模板(SYSTEM_PROMPT)的初稿由 AI 生成,经作者多轮实测调整;部分段落表述经 AI 润色。所有代码、报错记录、延迟与剧透率数据均为作者在上述实验环境中实机运行所得,已逐条复核。文中未使用 AI 生成图片。