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_code 和 error_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)
把 RichTextLabel 的 BBCode 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) 信号异步返回——注意 body 是 PackedByteArray,中文必须用 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_done 里 access_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),敏感词三层拦截逻辑经多轮对话验证。如发现事实或代码错误,欢迎在评论区指正。
