AB
AiBoss站
教程

LocalJev 本地部署与双端点接入教程:用 Ollama 跑通 Jev 风格的类型安全判定 API

教程

LocalJev 本地部署与双端点接入教程:用 Ollama 跑通 Jev 风格的类型安全判定 API

LocalJev 把本地 LLM 包装成 Jev 风格的类型安全判定服务:输入状态与选项列表,返回各选项的概率分布与置信度,不产生任何自然语言回答。本教程覆盖 Bun 与 Ollama 的前置准备、环境变量配置、curl 验证、返回字段解读,以及在 Dart/Flutter 客户端中通过环境变量在云端与本地端点之间切换的完整实现。

Jev 这类判定型模型解决的是一个很具体的问题:让程序拿到「在给定选项里选哪个」的结构化答案,而不是一段需要再解析的自然语言。它对外暴露的接口形态是「输入文本 + 选项列表 = 每个选项的概率分布」,调用方拿到的永远是预先定义好的键名和归一化后的概率值。麻烦在于,这类服务通常需要排队等待访问资格,本地开发阶段很难随时调用。LocalJev 就是针对这个缺口出现的:它把本机已有的 OpenAI 兼容推理服务(例如 Ollama)包装成 Jev 风格的判定端点,让开发者在离线环境里也能跑通整条判定链路。

这篇教程面向需要在本地验证判定逻辑、或者希望在生产代码里保留云端与本地两套端点的开发者。读完之后应当能够独立启动一个 LocalJev 服务、用 curl 完成一次判定请求、读懂返回结构,并把客户端改造成通过环境变量切换端点的形式。

准备工作

运行时与依赖

LocalJev 使用 TypeScript 编写,运行在 Bun 之上,因此本机需要先具备 Bun 环境。素材中标注的 Bun 版本要求为 1.2 及以上。此外还需要一个 OpenAI 兼容的本地推理服务作为后端,Ollama 是其中一种选择,LM Studio 之类的同类服务同样可以承担这个角色,只要它提供 OpenAI 兼容的接口路径。

  • Bun 1.2 或更高版本
  • 一个已经拉取好模型的本地推理服务(以 Ollama 为例)
  • 能够访问本机回环地址的终端环境

需要提前确认的三件事

第一,后端推理服务必须已经在运行,并且模型已经下载完成。LocalJev 本身不包含模型权重,它只做请求转换与结果归一化。第二,需要知道后端服务的 OpenAI 兼容地址,Ollama 默认监听在本机 11434 端口,对应的兼容路径以 /v1 结尾。第三,需要确定要使用的模型名称,这个名称必须与后端服务中实际存在的模型标识一致,写错会导致上游请求失败。

关于访问资格与费用

LocalJev 采用 MIT 许可证发布,可以自由获取与修改。它本身不产生 API 调用费用,成本只体现在本地机器的算力消耗上。与之相对,云端版本的判定服务通常需要先获得访问资格,并且按调用次数计费。具体的价格、配额与地区可用性请以各服务官网当前公布的信息为准,本文不给出固定数字。

操作步骤

第一步:获取代码并安装依赖

把仓库克隆到本地后进入目录,用 Bun 安装依赖。整个过程不需要额外的包管理器。

git clone <localjev-repository>
cd localjev
bun install

安装完成后目录中会出现依赖锁文件,后续启动命令都基于这个目录执行。

第二步:启动后端推理服务

在另一个终端窗口里把模型跑起来。以 Ollama 为例,直接运行模型即可,首次运行会自动下载权重。

ollama run llama3.2

模型加载完成后保持这个终端不要关闭。如果希望验证后端是否就绪,可以另外发一个请求到兼容端点,确认能拿到正常的补全响应。

第三步:配置环境变量

LocalJev 通过三个环境变量定位上游服务:上游地址、上游密钥、上游模型名。本地推理服务通常不校验密钥,但字段仍需填写,填任意占位字符串即可。

export UPSTREAM_BASE_URL="http://127.0.0.1:11434/v1"
export UPSTREAM_API_KEY="ollama"
export UPSTREAM_MODEL="llama3.2"

三个变量的含义分别是:UPSTREAM_BASE_URL 指向 OpenAI 兼容接口的根路径,UPSTREAM_API_KEY 是调用上游时携带的凭证,UPSTREAM_MODEL 指定实际执行推理的模型标识。如果后端换成了别的服务,只需要改这三行,LocalJev 本身的代码不需要动。

第四步:启动服务

环境变量就绪后启动服务进程。

bun run start

服务启动后会在本机监听一个端口,等待判定请求。默认监听地址为回环地址,端口以启动日志输出为准。这个端点接受 POST 请求,请求体为 JSON。

第五步:用 curl 发起一次判定

下面这个例子模拟一条银行扣款通知,要求模型在两个问题上给出判定:一个是四选一的科目分类,一个是是或否的二值判断。

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "model": "jev-latest",
    "state": "2026年9月15日引き落とし確定通知: 引落口座 三井住友銀行大塚支店、引落明細 SMBCM(モビット) 32,000円",
    "questions": {
      "account_category": {
        "type": "choice",
        "instructions": "この引落明細の勘定科目を以下から1つ判定してください。",
        "criteria": {
          "debt_repayment": "借入金・ローン・分割払いの返済",
          "fixed_cost": "通信費・光熱費・サブスク等の定期固定費",
          "living_expense": "日用品・飲食等の生活防衛費",
          "tax_social": "税金・社会保険料等の公的支払い"
        }
      },
      "is_debt_related": {
        "type": "noul",
        "instructions": "この明細は債務・借入の返済に関するものですか?"
      }
    }
  }'

请求体里有几个关键字段需要理解清楚。state 是待判定的原始文本,也就是模型要看的上下文。questions 是一个映射,键名由调用方自己定义,值描述这一个问题的类型与判定依据。typechoice 时表示多选一,需要额外提供 criteria,其中每个键是一个候选答案,值是给模型看的判定说明。typenoul 时表示二值判断,只需要 instructions,不需要候选列表。

第六步:解读返回结构

一次成功的判定会返回类似下面的结构。

{
  "model": "localjev-0.2",
  "answers": {
    "account_category": {
      "type": "choice",
      "choice": "debt_repayment",
      "probabilities": {
        "debt_repayment": 1.0,
        "fixed_cost": 0.0,
        "living_expense": 0.0,
        "tax_social": 0.0
      },
      "confidence": 1.0
    },
    "is_debt_related": {
      "type": "noul",
      "noul": 1.0
    }
  },
  "usage": {
    "input_tokens": 388,
    "output_tokens": 45
  }
}

返回体里没有任何自然语言句子,只有调用方自己定义的键名、归一化后的概率分布,以及由分布熵计算出的 confidence。对于 choice 类型,choice 字段给出概率最高的那个候选键,probabilities 给出全部候选的完整分布,两者配合可以判断这次判定是明确还是模糊。对于 noul 类型,返回的 noul 是一个介于 0 和 1 之间的数值,表示肯定程度。usage 字段记录本次请求消耗的输入与输出 token 数,便于估算本地算力开销。

一个完整示例

下面把前面的步骤串成一条可以完整跑通的链路,从零开始到拿到判定结果。

环境准备

# 终端 A:启动后端模型
ollama run llama3.2

# 终端 B:获取并启动 LocalJev
git clone <localjev-repository>
cd localjev
bun install
export UPSTREAM_BASE_URL="http://127.0.0.1:11434/v1"
export UPSTREAM_API_KEY="ollama"
export UPSTREAM_MODEL="llama3.2"
bun run start

发起判定

服务启动后,在终端 C 中发送请求。这里换一个更贴近日常记账的场景:一条通信费扣款记录,需要判断它属于哪一类支出。

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "model": "jev-latest",
    "state": "2026年9月10日 携帯電話料金 引落 8,420円 通信事業者",
    "questions": {
      "account_category": {
        "type": "choice",
        "instructions": "この引落明細の勘定科目を以下から1つ判定してください。",
        "criteria": {
          "debt_repayment": "借入金・ローン・分割払いの返済",
          "fixed_cost": "通信費・光熱費・サブスク等の定期固定費",
          "living_expense": "日用品・飲食等の生活防衛費",
          "tax_social": "税金・社会保険料等の公的支払い"
        }
      }
    }
  }'

预期返回中 account_category.choice 应为 fixed_costprobabilities 中该键的数值最高。如果返回的分布比较分散,说明模型对这条文本的判定不够确定,此时应当考虑调整 instructions 的措辞,或者换一个对分类任务更敏感的模型。

在客户端中接入双端点

本地跑通之后,常见的做法是让同一份客户端代码既能连本地端点,也能连云端端点,通过环境变量决定用哪一个。下面是一个 Dart/Flutter 客户端的实现思路。

class JevClient {
  static const String defaultEndpoint = '<cloud-endpoint>';
  static const String localJevEndpoint = 'http://127.0.0.1:<port>';

  final String? apiKey;
  final String endpoint;
  final Duration timeout;
  final http.Client _client;

  JevClient({
    this.apiKey,
    String? endpoint,
    this.timeout = const Duration(milliseconds: 800),
    http.Client? httpClient,
  })  : endpoint = endpoint ??
            (const bool.fromEnvironment('USE_LOCAL_JEV', defaultValue: false)
                ? localJevEndpoint
                : defaultEndpoint),
        _client = httpClient ?? http.Client();

  bool get isLocalMode =>
      endpoint.contains('127.0.0.1') || endpoint.contains('localhost');

  bool get isConfigured =>
      isLocalMode || (apiKey != null && apiKey!.trim().isNotEmpty);

  Future<JevClassificationResult?> classify({
    required String input,
    required List<JevChoice> choices,
  }) async {
    if (!isConfigured || choices.isEmpty) return null;
    try {
      final response = await _client
          .post(
            Uri.parse(endpoint),
            headers: {
              'Content-Type': 'application/json',
              if (!isLocalMode) 'Authorization': 'Bearer $apiKey',
            },
            body: jsonEncode({
              'input': input,
              'choices': choices.map((c) => c.toJson()).toList(),
            }),
          )
          .timeout(timeout);
      if (response.statusCode == 200) {
        return JevClassificationResult.fromJson(jsonDecode(response.body));
      }
    } catch (e) {
      // 出错时静默退回本地规则
    }
    return null;
  }
}

这段代码里有几个设计点值得说明。isLocalMode 通过端点字符串里是否包含回环地址来判断当前是不是本地模式,本地模式下不要求提供 API 密钥,这样在没有网络的环境里也能直接构造客户端。isConfigured 把「本地模式」和「已提供密钥」合并成一个可用性判断,避免在配置缺失时发出注定失败的请求。timeout 默认设置为 800 毫秒,这个值对本地推理来说偏紧,实际使用时需要根据本机模型速度调整,否则容易在模型还没算完时就超时返回。classify 方法在捕获异常后返回 null,调用方可以据此退回到基于规则的本地判断逻辑,保证判定层不会因为服务不可用而阻塞整个流程。

切换方式

开发阶段用本地端点启动,不需要网络也不需要密钥:

flutter run --dart-define=USE_LOCAL_JEV=true

生产构建时注入云端端点和对应的密钥,代码本身不需要任何改动。这种做法的价值在于,判定逻辑的验证可以在完全离线的条件下完成,而部署时又能切换到延迟更低的云端基础设施。

注意事项

概率来源与精度

LocalJev 返回的概率并不是从模型内部直接读取的数值,而是模型自己输出的「自报概率」经过校验、重试与归一化之后的结果。这一点与云端版本存在本质差别:云端版本直接读取解码器层的数值分布,因此延迟可以压到几十毫秒级别;LocalJev 走的是「把判定请求转换成分类提示词,再解析上游输出」的路径,延迟取决于背后本地模型的性能,通常在数百毫秒到数秒之间。如果对判定精度有严格要求,应当先对所选模型做校准评估,确认它的自报概率与实际准确率之间的偏差在可接受范围内。

上游服务的限制

LocalJev 依赖上游服务暴露 OpenAI 兼容接口。如果所选本地推理服务不提供这类接口,或者接口路径与预期不符,请求会直接失败。此外,本地推理服务通常不校验密钥,但环境变量仍需填写非空值,否则部分实现会在启动阶段报错。

超时与失败处理

本地推理的响应时间波动较大,首次加载模型时尤其明显。客户端超时时间设置过短会导致大量请求在模型完成计算前被中断。建议在客户端保留失败回退路径,让判定层在服务不可用时退回到规则判断,而不是让整个业务流程报错。

离线与在线的取舍

本地模式的优势是完全离线、零调用成本,适合开发调试与无网络环境;代价是延迟更高、精度依赖本地模型质量。云端模式的优势是低延迟与更稳定的判定质量,代价是需要访问资格、需要密钥、按调用计费。两种模式共用同一套请求与响应结构,因此可以在不改动业务代码的前提下切换。涉及费用、配额与访问资格的具体政策,请以相关服务官网当前公布的信息为准。