标签: Ollama

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