本文解决什么问题
解谜游戏最怕玩家卡关流失。给游戏加一个”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 Key 与 Secret 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_hint 的 Line 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 关进玩法的笼子里
裸接大模型的第一版,玩家问”提示”,它直接把答案背出来了——”把红色钥匙插进左侧门锁然后向右转两圈”。这不是提示器,这是攻略机器人。
我做了三件事把它按住:
- 状态注入:把
level / items / fails塞进 user 消息。模型不知道的道具,它就编不出来。 - 失败次数分级:
fails < 2时提示词里追加”只提示应该关注哪个区域”;fails ≥ 5才允许”点明需要用哪件道具,但不说怎么用”。 - 兜底白名单:把每关的答案关键词(比如
"左侧门锁")存在本地表里,收到返回后做一次字符串匹配,命中就丢弃并降级为静态提示。
-- 收到 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_history 和 flag 字段——这是内容安全审核拦截。触发原因往往很无辜:我有一关的道具叫”炸药包”。
处理方式只有两条:给关键道具起个中性别名再送进模型(”炸药包”→”道具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.lua、hint.gui_script、proxy.mjs),可按步骤复制到本地 Defold 项目中运行。main.collection 只需挂载一个引用 hint.gui 的 GUI 组件,无需额外配置。把 M.endpoint 换成你自己的中转地址,环境变量里填上 QF_AK / QF_SK,即可跑通。
发布日期:2026-07-10。本文涉及千帆接口路径与模型名,属版本敏感内容,建议每季度复核一次。
🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。
具体范围:文中提示词模板(SYSTEM_PROMPT)的初稿由 AI 生成,经作者多轮实测调整;部分段落表述经 AI 润色。所有代码、报错记录、延迟与剧透率数据均为作者在上述实验环境中实机运行所得,已逐条复核。文中未使用 AI 生成图片。








