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 解析、导出后连接失败)为作者复现记录。

评论

发表回复

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