numen-api 开发者文档

numen-api是驱动 Numen AI 同伴的引擎,也是插件对接的稳定公共 API。这一页只讲这一个项目:如何搭一个NeoForge 1.21.1 mod、通过numen-maven加依赖、实现并注册一个工具、以及引擎如何调用它。契约以1.21.1 分支源码为准。

01概览与架构

numen-api 是一套“让大模型真正活在 Minecraft 里”的引擎(harness)。它给模型套上一具在服务端按原生玩家规则行动的身体,把感知、行动、结果反馈接回模型。引擎自身不带任何工具——能力由上层工具包(numen-core / Numen mod)和集成(桥、MCP)在其上叠加。你就是那个叠加能力的人。

身体 = 真玩家同伴是服务端假玩家 NumenPlayer extends ServerPlayer,所有动作走原生玩家代码路径,天生兼容红石、生物 AI、容器与第三方模组。
大脑 = 客户端对话回路 EntityAgentLoop 跑在玩家自己的客户端、用玩家自己的 API key,服务端不装、不烧钱。
工具 = 手/眼模型能调用的动作单元。numen-api 只定义 NumenTool 契约并调度它,工具本体由你或工具包提供。
反馈循环每次工具结果(TaskResult)都写成一行“教模型 Minecraft 规则”的文字,拼回对话。

引擎是纯调度器,不是执行器。它只负责把工具展示给 LLM、投递调用、把结果路由回对话。 工具怎么干活(发服务端包、开线程、调外部服务)完全是你的事——有且只有一个“完成”动词 ToolCall.complete()

零第三方运行时依赖:LLM 传输仅用 JDK 自带 java.net.http.HttpClient + Gson(Gson 已在 MC 类路径上)。兼容 OpenAI 接口的模型接入内建 DeepSeek / 通义 / Kimi / GLM / 豆包 / MiniMax / SiliconFlow / OpenAI 等。

02环境要求

  • Minecraft 1.21.1
  • 加载器 本页示例用 NeoForge 21.1.233(loader 范围 [4,));也发布 Fabric / Forge 制品
  • Java 21
  • 构建 ModDevGradle net.neoforged.moddev 2.0.141(MultiLoader-Template 布局:common + 各加载器子项目)
  • 运行时引擎 由玩家安装的 Numen mod 提供(它把完整引擎打包在身上)——你的 mod 只 compileOnly 精简 API,不携带引擎

engine 跑在主人的客户端进程,同伴由主人客户端驱动。别把两套版本号混淆:Numen 模组版本、numen-api Maven 制品版本(本分支 0.0.2-SNAPSHOT)是不同编号。mod id 是 numen_api

03NeoForge 接入

制品发布在 numen-maven 静态仓库(GitHub raw 当 Maven 用)。先加仓库:

repositories {
    // Numen 制品仓库:GitHub raw 当静态 Maven,大陆可直连、无需账号。
    maven { url = 'https://raw.githubusercontent.com/Dwinovo/numen-maven/main' }
}

再在一个 NeoForge 1.21.1 mod 里 compileOnly 精简 :api jar:

// build.gradle —— 一个 NeoForge 1.21.1 mod(ModDevGradle)
plugins {
    // https://projects.neoforged.net/neoforged/moddevgradle 查新版本
    id 'net.neoforged.moddev' version '2.0.141'
}

neoForge {
    version = '21.1.233'   // NeoForge for MC 1.21.1(numen-api 本分支就构建于此)
}

// :api 是 SNAPSHOT,每次发布带新时间戳 —— 关掉缓存才能拿到最新
configurations.all {
    resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
}

dependencies {
    // 只需 compileOnly 精简版 :api jar(classifier = api,仅含公开接口)。
    // 运行时的完整引擎由玩家安装的 Numen mod 提供 —— 你的 mod 不打包任何引擎代码。
    compileOnly 'com.dwinovo.numen:numen-api-neoforge-1.21.1:0.0.2-SNAPSHOT:api'
}

坐标 = com.dwinovo.numen:numen-api-<loader>-<mcversion>:<version>:api,加载器与 MC 版本都烤进 artifactId。 换 Fabric / Forge 只需把 neoforge 换成 fabric / forge;换 MC 版本替换版本号即可,以 numen-maven 仓库实际存在的目录/版本为准(当前 neoforge-1.21.1 已发布 0.0.1-SNAPSHOT0.0.2-SNAPSHOT)。

只用 compileOnly,不要打包引擎。:api jar 只含公开接口;完整引擎在运行时由玩家安装的 Numen mod 提供。请在你的 neoforge.mods.toml 里声明对 numen_api 的依赖,确保加载顺序在引擎之后。

04核心概念

  • 同伴身体NumenPlayer extends ServerPlayer,稳定的每同伴 UUID,走原生玩家交互/战斗路径;由 getUUID() 取 UUID。
  • 工具(Tool):模型能调用的动作单元。实现 NumenTool 四个方法、在 ToolRegistry 注册。
  • 结果(TaskResult):工具交回 agent 循环的信封,序列化成模型读到的 role:tool 消息。
  • 三扇门:插件通过三个入口接触引擎——两扇给同伴喂输入(NumenGateway / NumenActuator),一扇教它新能力(ToolRegistry.register)。见下一节。
  • Skillconfig/numen/skills/ 下的 Markdown 工作流,零代码、按需加载。

05三扇门(公开 API 表面)

公开 API = 下面这几个包里、且未标 @Internal 的类型(沿用 Applied Energistics 2 的“package-info 声明公开”约定)。其余一切都是内部实现,随时可变。

公开类型作用
apiNumenGateway · NumenActuator门一 / 门二:喂输入、驱动同伴
agent.toolNumenTool · ToolCall · ToolRegistry · ToolArgs门三:工具契约、注册、参数读取器
agent.tool.apiToolContext服务端工具构建 task 的单次调用上下文
taskTaskResult工具回给 agent 循环的结果信封
entityNumenPlayer服务端同伴身体
  • 门一 · NumenGateway.enqueue:把消息喂给同伴的内置大脑,由内置 LLM 决定怎么做(入站桥用它)。
  • 门二 · NumenActuator:让外部大脑绕过内置 LLM,直接列工具、接管身体、调工具、读结果(MCP 服务器用它)。
  • 门三 · NumenTool + ToolRegistry.register:教同伴一项新能力。

没有注解层:工具契约就是直接实现 NumenTool 的四个方法——numen-api 不提供 @NumenAction 之类的注解或反射作者层。numen-core 另提供可选的 Schema builder 与 ServerNumenTool 基类作便利糖,但不属于 numen-api 契约。另:agent.skill 整包为 @Internal——Skill 用 Markdown 编写,不写 Java。

06写一个工具

契约就是四个方法,没有注解层。parameterSchema() 用 OpenAI 工具参数的 JSON Schema 方言。

public interface NumenTool {
    // 模型看到的工具名,snake_case
    String name();

    // 给模型的描述 —— 决定模型能否正确选中此工具的最大杠杆:
    // 讲清楚干什么、何时用(何时别用)、每个非显然参数的含义、注意事项
    String description();

    // 参数的 JSON Schema(OpenAI 工具参数方言)
    Map<String, Object> parameterSchema();

    // 执行一次调用。现在干、稍后干、客户端干、丢给外部服务干都行;
    // 结果就绪时通过 ToolCall.complete(...) 回报。契约里刻意没有任何
    // Minecraft / 服务端 / 任务队列的概念 —— 怎么干完全是工具自己的事。
    void invoke(ToolCall call);
}

把它接到第 03 节那个 NeoForge mod 上——一个最干净的纯 API 工具(只做外部 I/O,不碰世界):

// AnnounceTool.java —— 一个纯 API 工具:把同伴想说的话推给外部服务,做完 complete。
// 演示最干净的一类工具:不碰 Minecraft 世界,只做外部 I/O(桥接/通知/Web API)。
package com.example.companiontools;

import com.dwinovo.numen.agent.tool.NumenTool;
import com.dwinovo.numen.agent.tool.ToolCall;
import com.dwinovo.numen.task.TaskResult;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.Map;

public final class AnnounceTool implements NumenTool {

    @Override public String name() { return "announce"; }

    @Override public String description() {
        return "Post a short message to the owner's external chat. "
             + "Use when you want to tell the owner something out-of-game.";
    }

    @Override public Map<String, Object> parameterSchema() {
        return Map.of(
            "type", "object",
            "properties", Map.of(
                "text", Map.of("type", "string", "description", "what to say")
            ),
            "required", List.of("text")
        );
    }

    @Override public void invoke(ToolCall call) {
        JsonObject args = call.args();            // 非法 JSON → IllegalArgumentException
        String text = args.get("text").getAsString();

        // —— 想怎么干就怎么干:这里同步 POST 给一个外部 webhook;
        //    也可以切线程 / 丢消息队列,稍后再 complete。 ——
        boolean sent = MyWebhook.post(text);

        // 唯一的“完成”动词,恰好调用一次。参数即喂回模型的 role:tool 内容。
        call.complete(sent
            ? TaskResult.ok("delivered").toJson()
            : TaskResult.fail("webhook unreachable").toJson());
    }
}

description() 是模型能否选对工具的最大杠杆,值得像写文档一样打磨;parameterSchema() 的返回是普通 Map,引擎序列化成 schema 交给模型。

07参数与上下文

ToolCall 是引擎交给 invoke 的全部东西 + 唯一回报动词。args() 返回 Gson 的 JsonObjectcomplete(String) 交回结果。

@Override public void invoke(ToolCall call) {
    call.id();            // 模型的 tool_call id,原样回带到结果消息
    call.toolName();      // "announce"
    call.rawArgs();       // 模型原样吐出的参数 JSON 串
    JsonObject a = call.args();   // 解析后;非法 JSON 抛 IllegalArgumentException
    call.ctx();           // 客户端上下文(下方说明);身体不在视距内时其 entity 可能为 null

    // numen-api 自带一组参数读取器(agent.tool.ToolArgs,公开),省去手写校验:
    int qty    = ToolArgs.requireInt(a, "count", 1, 64);   // 必填 + clamp 到 [1,64]
    Item item  = ToolArgs.requireItem(a, "item");          // "minecraft:iron_ingot" → Item
    BlockPos p = ToolArgs.optionalPos(a);                  // x/y/z 全给或全不给;缺一个报错

    // 以上都在非法输入时抛 IllegalArgumentException,
    // 引擎会转成 success:false 的结果交回模型,让它下一轮自我纠正。
}

ctx() 返回 ClientToolContext(客户端句柄,本身标了 @Internal,但它是公开 invoke 路径会递给你的那个内部类型):其 anchor() 给出扫描/物品栏的中心实体,身体不在视距内时 entity 可能为 null,感知类工具须判空并返回干净失败而非 NPE。

驱动身体:纯 I/O 工具在 invoke 里直接干活即可;要写会真正驱动同伴身体行动的工具,则自定义网络包把动作送到服务端、在服务端操作 NumenPlayer(见第 10 节)。

08注册与调用

// 在模组初始化时注册一次(NeoForge:mod 构造器内)。
ToolRegistry.register(new AnnounceTool());

// 注册表要点:
//  · 静态全局单例 —— 工具集是“部署级”决策,不是 per-entity
//  · 保留插入顺序 → 模型每次看到的工具顺序稳定,利于后端 prompt 缓存
//  · register 遇重名抛 IllegalStateException(暴露接线 bug,不静默覆盖)
//  · resolve(name) 精确匹配优先,再忽略大小写兜底(容忍 Move_to / move_to 漂移)

你写完注册就结束了。剩下的链路由引擎跑,无需你参与:

// 引擎(客户端 EntityAgentLoop)如何调用你的工具 —— 这段你不用写,看清链路即可:
//
// 1. 每次请求,引擎把 ToolRegistry.all()(保序)作为工具清单喂给模型
// 2. 模型回一个 tool_call:{ id:"call_7", name:"announce", arguments:"{\"text\":\"hi\"}" }
// 3. 引擎 resolve("announce") 找到你的实例(精确匹配,大小写漂移兜底)
// 4. 引擎构造 ToolCall(id, name, rawArgs, ctx, sink),调用 tool.invoke(call)
// 5. 你在 invoke 里干活,随时随线程 call.complete(resultJson)
// 6. 引擎把 resultJson 作为 role:tool 消息(带回同一个 id)拼进对话,交给模型下一步

09工具结果

TaskResult 是个 record(success / message / timedOut / interrupted / data),工厂方法覆盖常见结局:

TaskResult.ok("拿到 64 个铁锭");
TaskResult.ok("已放进箱子", Map.of("count", 64, "slot", 3));   // 附结构化 data
TaskResult.fail("背包满了");
TaskResult.fail("路被挡住", Map.of("blocked_at", "100,64,-12"));
TaskResult.timeout("寻路超时");        // success=false, timed_out=true
TaskResult.cancelled("被主人打断");    // success=false, interrupted=true

// 交回引擎前序列化成模型读到的 role:tool 内容:
String json = TaskResult.ok("done").toJson();
// => {"success":true,"message":"done"}
// data 里 Number/Boolean 原样入 JSON,其余值 toString();空 data 与 false 标志位省略

data 装任务专属结构化信息(键为小写 snake_case,值须 Gson 可序列化)。toJson() 恒输出 success/message,仅在为真时才加 timed_out/interrupted/非空 data。信封形状仿 Mindcraft,便于小模型直接读懂。

10同伴身体

NumenPlayerentity 包唯一公开类)是服务端假玩家,供服务端侧代码查询状态、驱动身体、读物品栏。

// NumenPlayer 是服务端假玩家(extends ServerPlayer)——
// 只有当你的工具把活儿送到【服务端】执行(自定义网络包)时才拿它。
NumenPlayer body = NumenPlayer.findByUuid(server, companionUuid);   // 未加载返回 null
if (body != null && body.isOwnedByPlayer(ownerUuid)) {   // 跨维度安全:纯 UUID 比较
    boolean hasPick = body.ensureInInventory(Items.DIAMOND_PICKAXE);  // 是否在物品栏
    body.holdInHand(slot);                     // 正确切主手(勿用 setItemInHand 覆盖)
    ServerPlayer owner = body.resolveOwnerPlayer();      // 主人离线为 null
}

常用公开方法:findByUuid(server, uuid) · getOwnerUuid/setOwnerUuid · isOwnedByPlayer(跨维度安全,纯 UUID 比较,勿用原版 isOwnedBy)· resolveOwnerPlayer(离线为 null)· ensureInInventory(item)(是否在物品栏,返回 boolean)· holdInHand(slot)(按原生方式切主手,热键槽直接选中、背包槽与手中互换)。

11门一 · 入站消息

NumenGateway.enqueue 是“从聊天 GUI 之外喂消息给同伴内置大脑”的唯一公开入口——Discord 桥、QQ 桥、直播弹幕皆可。

import com.dwinovo.numen.api.NumenGateway;

// 门一·入站:从聊天 GUI 之外,把一条消息【原样】喂给某同伴的内置大脑。
// 必须在【主人的客户端进程】里调用;任意线程安全(内部切客户端主线程)。
boolean queued = NumenGateway.enqueue(companion.getUUID(), "有人在 QQ 说:去挖一组铁");
// 消息为空、或该同伴本局从未被召唤 → 返回 false
  • 原样入队:字符串逐字进同伴的“主人提示队列”,在下一个协议合法点(工具批次边界)被模型看到;多条会合并成一条 user 消息。同伴空闲时立即起一轮。
  • 来源标签、限流、翻译、权限校验全由调用方负责——引擎只定义“给某同伴入队一个字符串”这一个动词。
  • 出站不走这里:回信通过注册工具(如第 06 节的 announce)离开同伴。入站 = 消息队列,出站 = 工具调用;工具调用队列本身刻意封死。

参考实现:numen-qq-bridge——整座桥只用了 NumenGateway.enqueue(入站)+ ToolRegistry.register(出站)两个入口。

12门二 · 外部大脑

NumenActuator外部智能体(一个 MCP 服务器、一个远程 agent、任何想自己做游戏决策的东西)绕过内置 LLM,直接驱动同伴身体。契约是 acquire → invoke* → release

import com.dwinovo.numen.api.NumenActuator;
import java.util.UUID;

// 门二·外部大脑:绕过内置 LLM,由外部智能体(如一个 MCP 服务器)直接驱动身体。
// 契约:acquire → invoke* → release;三者都返回 CompletableFuture,任意线程可调。
NumenActuator.companions().thenAccept(fleet -> {          // 主人当前在世的同伴列表
    UUID body = fleet.get(0).uuid();                      // Companion(uuid, name)
    NumenActuator.acquire(body)                           // 暂停内置大脑、腾出身体
        .thenCompose(ok ->
            NumenActuator.invoke(body, "move_to",         // 无头调用任意已注册工具
                "{\"x\":100,\"y\":64,\"z\":-200}"))
        .thenAccept(resultJson -> handle(resultJson))     // 一个 TaskResult JSON 串
        .whenComplete((r, e) -> NumenActuator.release(body));  // 始终交还身体
});
// 每次调用都指向一个 UUID,各身体并行跑任务:一个外部大脑可 acquire 一支“舰队”。
// 失败(未知工具 / 参数错 / 工具抛异常)都以 TaskResult.fail 的 JSON 返回,future 不异常完成。
  • companions()CompletableFuture<List<Companion>>:主人当前在世的同伴,每个是 Companion(UUID uuid, String name)
  • acquire(uuid):暂停该同伴内置大脑、停掉在飞的内部回合/任务、腾出身体,两个大脑不会抢一具身体。幂等;非本人同伴返回 false
  • invoke(uuid, toolName, argsJson)CompletableFuture<String>:无头调用任意已注册工具,返回 TaskResult 的 JSON 串。不触碰同伴对话记录(上下文由外部大脑自持)。
  • release(uuid):交还控制权(内置大脑不自动起,等下一条主人消息)。幂等。
  • 并行免费:每次调用都指向一个 UUID,各身体独立跑任务——一个外部大脑可 acquire 一支舰队并行驱动。

全部方法客户端调用、任意线程安全(内部切客户端主线程,用 CompletableFuture 回传)。官方参考实现:numen-mcp(一个基于 NumenActuator 的 Model Context Protocol 服务器,让 Claude 之类直接驱动同伴)。

13Skill(零代码)

Skill 是零代码扩展,与写工具是两条完全不同的路径。放进 config/numen/skills/ 的 Markdown 工作流,按需加载以保持 prompt 精简。

---
name: 打末影龙
description: 从零到击杀末影龙的完整流程。
---

1. 备齐弓、箭、床、黑曜石……
2. 找到末地传送门要塞……
(正文就是给模型看的工作流,纯 Markdown,零 Java)

frontmatter 只支持单行 key: value(无嵌套/数组),仅需 name + description,其余为正文。解析器住在 agent.skill(整包 @Internal)——你只写 Markdown,不调它的 Java。

14API 速查

符号关键点
NumenToolagent.toolname / description / parameterSchema / invoke(ToolCall)
ToolCallagent.toolid toolName rawArgs args(JsonObject) ctx complete(String)
ToolRegistryagent.toolregister(重名抛异常) / resolve / all / size
ToolArgsagent.toolrequireInt / requireItem / optionalPos …(非法抛 IllegalArgumentException
TaskResulttaskrecord;ok/fail/timeout/cancelled + toJson()
NumenGatewayapienqueue(UUID, String) → boolean
NumenActuatorapicompanions / acquire / invoke / release(均 CompletableFuture
NumenPlayerentityfindByUuid / isOwnedByPlayer / ensureInInventory / holdInHand

完整方法契约见 源码 common/…/numen/;numen-maven 每个版本另发 -javadoc.jar-sources.jar,IDE 里能读到全部方法签名与文档。

15许可

  • 源代码 LGPL-3.0-only——你分发的修改版须同样开源。
  • 公共对接 API 面com.dwinovo.numen.api 等你 compileOnly 的那层)MIT——写兼容/闭源商业项目均可自由使用。
  • 美术与素材 All Rights Reserved;“Numen” / “言出法随” 名称保留。