Lua 与 AI/LLM:Agent 脚本、NPC 智能与动态内容

探索 Lua 在 AI 时代的角色:Lua 作为 Agent 脚本语言与 LLM 调用封装、基于协程的多步工作流编排、游戏 NPC 行为树与 LLM 驱动动态内容生成,以及成本、延迟与安全的工程实践。

Lua 在 AI 时代的独特位置

当大语言模型(LLM)成为新的「计算中心」,需要大量可嵌入、轻量、可热更新的脚本语言来承载 Agent 的决策逻辑、工具调用与游戏 NPC 的行为。Lua 凭借三个特质再度进入 AI 工程视野:

-- Lua 作为 Agent 策略层的典型形态:
-- 一个策略脚本描述「何时调用工具、如何继续」
local function decide(context)
    if context.need_help then
        return { action = "call_llm", prompt = build_help_prompt(context) }
    elseif context.retry_quota > 0 then
        return { action = "retry", delay = 2 }
    end
    return { action = "finalize" }
end

return decide

用 Lua 封装 LLM 调用

在纯 Lua 中调用 LLM 的核心是 HTTP + JSON:发起请求、解析响应、处理流式返回。

HTTP 请求封装

LuaJIT 无内建 HTTP,需要借助 LuaSocket 或 OpenResty 的 cosocket(见 OpenResty 网关开发实战)。这里展示一个通用的 llm.request 封装:

-- llm.lua —— 极简 LLM 客户端封装
local http = require("socket.http")
local ltn12 = require("ltn12")

local M = {}

local function build_payload(model, messages, opts)
    return {
        model = model,
        messages = messages,
        temperature = opts.temperature or 0.7,
        max_tokens = opts.max_tokens or 1024,
    }
end

function M.chat(model, messages, opts)
    local payload = build_payload(model, messages, opts)
    local response_body = {}

    local ok, status = http.request {
        url = "https://api.example.com/v1/chat/completions",
        method = "POST",
        headers = {
            ["Content-Type"] = "application/json",
            ["Authorization"] = "Bearer " .. os.getenv("LLM_API_KEY"),
        },
        source = ltn12.source.string(json.encode(payload)),
        sink = ltn12.sink.table(response_body),
    }

    if not ok or status ~= 200 then
        return nil, ("LLM 请求失败: HTTP %s"):format(tostring(status))
    end

    local decoded = json.decode(table.concat(response_body))
    return decoded.choices[1].message.content
end

return M

JSON 解析与结构化输出

LLM 响应经常要求结构化(JSON、代码、配置),Lua 侧需要稳健的解析。生产环境应要求模型输出 Markdown 代码块包裹的 JSON,并用容错解析器提取:

local function extract_json(text)
    -- 提取 ```json ... ``` 代码块
    local start_pos = text:find("```json[^\n]*\n")
    if start_pos then
        local body_start = start_pos + ("```json[^\n]*\n"):len()
        local body_end = text:find("```", body_start, true)
        if body_end then
            local ok, decoded = pcall(json.decode, text:sub(body_start, body_end - 1))
            if ok then return decoded end
        end
    end
    -- 兜底:直接尝试解析
    local ok, decoded = pcall(json.decode, text)
    return ok and decoded or nil
end

流式响应与协程

LLM 的流式(SSE)响应用协程包装后,消费端可以同步风格迭代:

local llm = require("llm")

-- 流式请求:返回一个生成器(基于协程)
local function stream_chat(model, messages)
    return coroutine.wrap(function()
        -- 伪代码:向服务端发起 SSE 请求
        local sse = llm.stream(model, messages)
        for chunk in sse do
            if chunk.type == "content" then
                coroutine.yield(chunk.text)
            end
        end
    end)
end

-- 使用:像迭代器一样消费 token
local gen = stream_chat("gpt-4o-mini", {{ role = "user", content = "写一首诗" }})
for token in gen do
    io.write(token)   -- 逐 token 输出
end

协程在这里的核心价值:把回调式流式处理变成线性代码,逻辑可读性大幅提升,这正是 Lua 协程深入解析 中异步 IO 模式的直接应用。

Agent 脚本语言:把决策写进脚本

Agent 的「行为策略」非常适合用 Lua 编写:用户(或游戏设计师)可以安全地修改策略脚本,而无需改动宿主程序。宿主暴露一组工具函数,Lua 侧决定调用顺序。

工具调用的注册

-- host 侧:注册工具
local tools = {
    search = function(q) return web_search(q) end,
    execute = function(cmd) return run_sandboxed(cmd) end,
    remember = function(key, value) return storage.set(key, value) end,
}

-- 暴露给 Lua 的全局
_G.tool = setmetatable({}, {
    __index = function(_, name)
        return tools[name]
    end,
})

Agent 循环

-- agent.lua —— 宿主驱动的 Agent 主循环
local llm = require("llm")

local function run_agent(prompt)
    local messages = {
        { role = "system", content = "你是助手。需要工具时输出 {\"tool\": \"name\", \"args\": {...}}。" },
        { role = "user", content = prompt },
    }

    local max_steps = 8
    for step = 1, max_steps do
        local reply = llm.chat("gpt-4o-mini", messages, { temperature = 0 })
        local tool_call = extract_json(reply)

        if not tool_call or not tool_call.tool then
            -- 模型没有工具调用,即最终回答
            return reply
        end

        -- 执行工具并把结果喂回给模型
        local ok, result = pcall(function()
            return tool[tool_call.tool](table.unpack(tool_call.args or {}))
        end)
        messages[#messages + 1] = { role = "assistant", content = reply }
        messages[#messages + 1] = { role = "tool", content = ok and result or ("错误: " .. result) }
    end

    error("Agent 达到最大步数")
end

这个「模型决策 + 脚本执行工具 + 结果回喂」的循环,是 ReAct 模式在 Lua 侧的最小实现。策略文件可以被热更新,实现 Agent 行为的线上迭代。

基于协程的工作流编排

复杂 Agent 任务往往是多步流水线:检索 → 规划 → 执行 → 校验。用 Lua 协程把每一「阶段」写成可暂停的任务,宿主调度器统一调度:

-- 工作流编排器:协程化阶段
local Workflow = {}
Workflow.__index = Workflow

function Workflow.new()
    return setmetatable({ steps = {} }, Workflow)
end

function Workflow:add(name, fn)
    self.steps[#self.steps + 1] = { name = name, fn = fn }
    return self
end

function Workflow:run(initial)
    local co = coroutine.create(function(ctx)
        for _, step in ipairs(self.steps) do
            local ok, result = pcall(step.fn, ctx)
            if not ok then
                ctx.error = result
                break
            end
            ctx[step.name] = result
            -- 每步结束让出,宿主可插入 LLM 调用或人工审批
            coroutine.yield(step.name, result)
        end
    end)
    return co
end

-- 使用:检索 → 生成 → 校验
local wf = Workflow.new()
    :add("retrieve", function(ctx)
        return tool.search(ctx.query)
    end)
    :add("draft", function(ctx)
        return llm.chat("gpt-4o", {
            { role = "user", content = "基于以下资料写摘要: " .. ctx.retrieve }
        })
    end)
    :add("verify", function(ctx)
        -- 调用模型自校验或规则检查
        return validate(ctx.draft)
    end)

local co = wf:run({ query = "LuaJIT 性能" })
while coroutine.status(co) ~= "dead" do
    local _, step_name, result = coroutine.resume(co)
    print("步骤完成:", step_name)
    -- 可在步骤之间插入人工确认/限流等待
end

这种「阶段即协程」的设计让长任务的暂停点、恢复点与错误传播都清晰可控。

游戏 NPC AI:行为树与状态机

行为树实现

Lua 是描述 NPC 行为树(Behavior Tree)的理想语言:树的节点就是 Lua 表,逻辑即脚本。一个极简行为树引擎:

-- bt.lua —— 极简行为树节点
local M = {}

-- 选择节点:依次尝试子节点直到一个返回 success
function M.selector(children)
    return function(ctx)
        for _, child in ipairs(children) do
            local status = child(ctx)
            if status ~= "failure" then
                return status
            end
        end
        return "failure"
    end
end

-- 顺序节点:依次执行,全部成功才 success
function M.sequence(children)
    return function(ctx)
        for _, child in ipairs(children) do
            local status = child(ctx)
            if status ~= "success" then
                return status
            end
        end
        return "success"
    end
end

-- 动作节点:执行行为
function M.action(fn)
    return function(ctx)
        return fn(ctx) and "success" or "failure"
    end
end

return M

NPC 行为描述:

local bt = require("bt")

local patrol = bt.sequence {
    bt.action(function(ctx) return move_to(ctx, ctx.patrol_point) end),
    bt.action(function(ctx) return wait(ctx, 3) end),
}

local attack = bt.sequence {
    bt.action(function(ctx) return in_range(ctx, ctx.target) end),
    bt.action(function(ctx) return strike(ctx) end),
}

local npc_ai = bt.selector {
    attack,               -- 有目标且在范围内则攻击
    patrol,               -- 否则巡逻
}

LLM 驱动的动态 NPC

把 LLM 接入行为树的叶子节点,NPC 就能产生「不可穷举」的对话与行为:

local llm = require("llm")

-- 对话叶子节点:调用 LLM 生成符合角色设定的回复
local function llm_talk(ctx, npc_profile, player_msg)
    local prompt = table.concat({
        "你是游戏中的 NPC," .. npc_profile,
        "玩家对你说: " .. player_msg,
        "用不超过 50 字、符合角色人设的口气回复,并附带一个动作描述。",
        "输出 JSON: {\"action\": \"...\", \"reply\": \"...\"}",
    }, "\n")

    local raw = llm.chat("gpt-4o-mini", {
        { role = "system", content = "游戏对话引擎" },
        { role = "user", content = prompt },
    })
    local parsed = extract_json(raw)

    if parsed then
        ctx.npc.do_action(parsed.action)
        return parsed.reply
    end
    return "(NPC 沉默)"
end

LLM 驱动 NPC 的工程要点:LLM 输出不可完全信任,必须叠加规则校验(敏感词、格式、动作白名单),并把生成结果缓存复用,控制成本与延迟。

动态内容生成与本地化

Lua 脚本也是「动态内容生成」的优秀宿主:用 LLM 生成关卡配置、任务文本、物品描述,再用 Lua 脚本组装与校验:

-- 动态任务生成
local function generate_quest(theme, level)
    local schema = {
        title = "string",
        objectives = "array",
        reward = "object",
    }
    local raw = llm.chat("gpt-4o-mini", {
        { role = "user", content = ("为 %s 主题、难度 %d 生成任务: %s"):format(
            theme, level, json.encode(schema)) }
    })
    local quest = extract_json(raw)

    -- 用 Lua 做结构校验与数值平衡
    assert(type(quest.title) == "string", "任务标题缺失")
    assert(#quest.objectives >= 1, "任务目标为空")
    quest.reward.exp = balance(level, quest.reward.exp)

    return quest
end

本地化场景同理:LLM 翻译的文案先经 Lua 脚本做占位符校验、长度约束与术语表替换,再进入游戏包。

成本、延迟与安全实践

Lua 承载 Agent 逻辑时,工程约束集中在三方面:

  • 成本控制:在 Lua 脚本层设置步数上限、token 预算、结果缓存;把高频确定性逻辑留在 Lua,只把「真需要智能」的部分发给 LLM。
  • 延迟处理:LLM 毫秒到秒级延迟,用协程 + 调度器让等待不阻塞主循环;对实时性要求高的 NPC 动作,预生成缓存结果。
  • 安全边界:Agent 工具调用必须在沙箱内执行(见 Lua 环境与沙箱);LLM 输出只允许结构化入参,禁止直接拼接成命令;敏感操作一律白名单。
-- 安全实践:工具调用白名单 + 参数校验
local allowed_tools = {
    search = true,
    read_config = true,
    -- execute 不在白名单,禁止 Agent 直接执行任意命令
}

local function safe_tool_call(name, args)
    if not allowed_tools[name] then
        return nil, "工具未授权: " .. name
    end
    -- 参数类型与范围校验
    if name == "search" and (#args.query > 200) then
        return nil, "查询超长"
    end
    return tool[name](args)
end

常见问题(FAQ)

Lua 适合写 Agent 逻辑吗?为什么不直接用 Python?

取决于场景。在游戏、嵌入式、网关这类「Lua 已经是宿主脚本」的环境中,Agent 逻辑直接用 Lua 写成本最低、可热更、无跨语言桥接。Python 生态(LangChain 等)在通用 Agent 开发更丰富;Lua 的价值在「已存在的 Lua 宿主里做智能增强」。

LLM 调用用同步还是异步?

必须异步。LLM 延迟可能数秒,同步调用会卡死游戏主循环或 Neovim UI。用协程包装异步回调(本文的流式示例),或交给事件循环调度,保证等待不阻塞。

NPC AI 用行为树还是 LLM?

两者互补而非互斥。行为树保证确定性、可调试、零延迟,适合巡逻、战斗等核心循环;LLM 负责对话、情景应变等「开放域」能力,作为行为树的叶子节点接入。纯 LLM 驱动 NPC 目前成本与稳定性都不可控。

如何避免 LLM 输出破坏游戏平衡?

所有生成结果进入 Lua 校验层:结构校验、数值钳制(clamp)、术语表替换、随机种子校验。生成的内容应视为「候选」,经规则通过后才生效,而不是直接信任模型输出。

Agent 的步数循环会不会失控?

在 Lua 脚本层强制步数上限(本文示例为 8 步),每步工具调用也设置超时。一旦超过阈值即终止并记录诊断信息。成本控制应与步数控制绑定,防止单个请求耗尽 token 预算。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. LuaRocks 发布与 CI:从 rockspec 到自动化分发
  2. Neovim 插件工程化:从架构到发布的完整指南
  3. Lua 测试工程化指南:busted 框架与 BDD 实践