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,服务端不装、不烧钱。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.moddev2.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-SNAPSHOT、0.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)。见下一节。 - Skill:
config/numen/skills/下的 Markdown 工作流,零代码、按需加载。
05三扇门(公开 API 表面)
公开 API = 下面这几个包里、且未标 @Internal 的类型(沿用 Applied Energistics 2 的“package-info 声明公开”约定)。其余一切都是内部实现,随时可变。
| 包 | 公开类型 | 作用 |
|---|---|---|
api | NumenGateway · NumenActuator | 门一 / 门二:喂输入、驱动同伴 |
agent.tool | NumenTool · ToolCall · ToolRegistry · ToolArgs | 门三:工具契约、注册、参数读取器 |
agent.tool.api | ToolContext | 服务端工具构建 task 的单次调用上下文 |
task | TaskResult | 工具回给 agent 循环的结果信封 |
entity | NumenPlayer | 服务端同伴身体 |
- 门一 ·
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 的 JsonObject;complete(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同伴身体
NumenPlayer(entity 包唯一公开类)是服务端假玩家,供服务端侧代码查询状态、驱动身体、读物品栏。
// 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 速查
| 符号 | 包 | 关键点 |
|---|---|---|
NumenTool | agent.tool | name / description / parameterSchema / invoke(ToolCall) |
ToolCall | agent.tool | id toolName rawArgs args(JsonObject) ctx complete(String) |
ToolRegistry | agent.tool | register(重名抛异常) / resolve / all / size |
ToolArgs | agent.tool | requireInt / requireItem / optionalPos …(非法抛 IllegalArgumentException) |
TaskResult | task | record;ok/fail/timeout/cancelled + toJson() |
NumenGateway | api | enqueue(UUID, String) → boolean |
NumenActuator | api | companions / acquire / invoke / release(均 CompletableFuture) |
NumenPlayer | entity | findByUuid / 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” / “言出法随” 名称保留。
