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 生成图片。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注