W1 教程 · 让模型先跑起来
对应计划表:阶段 A 地基,第 1 周。主块 4 小时 + 轻任务 2×1 小时。
本教程所有代码已在 JDK 17.0.18 + Maven 3.8.5 + LangChain4j 1.22.0 上实际编译、运行、并用假网关端到端验证通过。
配套可运行工程在
ecom-agent-lab/,bash selfcheck-w1.sh可一键自测(不花钱)。
0. 本周你要交付什么
一句话:用 Java 调通一次公司模型网关,并把 20 次调用的 token / 耗时 / 成本记成一张表。
不是"搭一个智能助手",不是"封装框架"。就是调通 + 记账。
验收标准(自己判卷):
- 一条命令能发出请求并打印模型回复
- CSV 里有 20 条记录,每条含:时间、输入 token、输出 token、耗时(ms)、成本
- 能不看文档说清"1 万 token 大概多少钱"(自己算的,不是背的)
- API Key 从环境变量读取,代码和 Git 里都搜不到
为什么第 1 周不写工程代码? 因为你要先建立"模型是有成本、有延迟、会失败的东西"这个直觉。周报写"本周学习了 LLM 基础",面试官毫无感觉;写"实测某模型 1 万 token 输入约 0.02 元,平均单次耗时 1.3 秒,输出长度方差很大",他知道你真跑过。
1. 环境准备(30 分钟)
1.1 你需要什么
| 项 | 要求 | 检查命令 |
|---|---|---|
| JDK | 17 或以上(LangChain4j 最低要求就是 17) | java -version |
| Maven | 3.6+ | mvn -v |
| 模型网关 | 地址 + 鉴权方式 + 可用模型名 | 问公司平台组 |
LangChain4j 官方文档原文:"The minimum supported JDK version is 17." 若公司老项目还在 JDK 8/11,这个练习工程单独用 17,别动生产项目。
1.2 环境变量(关键,别跳过)
Windows PowerShell(当前会话有效):
powershell
$env:LLM_BASE_URL = "https://your-gateway.company.com/v1"
$env:LLM_API_KEY = "网关分发给你的令牌"
$env:LLM_MODEL = "网关支持的模型名"
$env:LLM_PRICE_IN = "2" # 输入单价,单位:元/百万 token(向网关索取)
$env:LLM_PRICE_OUT= "8" # 输出单价,单位:元/百万 token
Windows 永久生效(新开终端才读到,如果是IDEA,需要重启IDEA):
powershell
[Environment]::SetEnvironmentVariable("LLM_BASE_URL","https://your-gateway.company.com/v1","User")
[Environment]::SetEnvironmentVariable("LLM_API_KEY","你的令牌","User")
[Environment]::SetEnvironmentVariable("LLM_MODEL","模型名","User")
[Environment]::SetEnvironmentVariable("LLM_PRICE_IN","2","User")
[Environment]::SetEnvironmentVariable("LLM_PRICE_OUT","8","User")
macOS / Linux:
bash
export LLM_BASE_URL="https://your-gateway.company.com/v1"
export LLM_API_KEY="你的令牌"
export LLM_MODEL="模型名"
export LLM_PRICE_IN="2"
export LLM_PRICE_OUT="8"
四条硬规矩:
- 不要把 Key 写进
application.yml并提交到 Git。这是安全事故,不是洁癖。 - 截图、发求助消息时,Key 必须打码。
- 单价也不要硬编进代码------网关调价那天你不希望重新发版。
- 如果网关令牌需要审批,今天就去提流程。这是唯一可能卡你一周的事。
1.3 网关适配性探测(本周最重要的一步)
公司网关通常有三种形态,处理方式完全不同:
| 形态 | 特征 | 你的做法 |
|---|---|---|
| A. OpenAI 兼容 | 路径 /v1/chat/completions,请求体 {"model","messages"} |
最省事,本教程代码直接可用 |
| B. 自家协议 | 路径、字段名都不同,如 /api/invoke、{"prompt":...} |
不能直接用 OpenAI 客户端,需自封装(W2 给完整方案) |
| C. 需二次鉴权 | 除 Bearer 还要签名头 / 租户 ID / AppId | 用 customHeaders 加,见下方代码 |
先拿 curl 试,别直接写 Java:
bash
curl -i -X POST "$LLM_BASE_URL/chat/completions" \
-H "Authorization: Bearer $LLM_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$LLM_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"
看三件事:
- 200 且返回
choices[0].message.content→ 形态 A,继续 - 404 → 路径不对,依次试
/v1/chat/completions、/chat/completions、/v1/completions - 401/403 → 鉴权方式不对,问网关文档要 header 格式
形态 C 的自定义请求头 (LangChain4j 1.22.0 支持,customHeaders 接一个 Supplier<Map<String,String>>):
java
import java.util.Map;
OpenAiChatModel.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.modelName(modelName)
.customHeaders(() -> Map.of(
"X-Tenant-Id", System.getenv("LLM_TENANT_ID"),
"X-App-Id", System.getenv("LLM_APP_ID")))
.build();
若网关是形态 B(完全不是 OpenAI 协议),不要硬 hack 。W2 会给一套纯
java.net.http.HttpClient的自封装方案,对任何协议都适用。
2. 建工程(15 分钟)
bash
mkdir ecom-agent-lab && cd ecom-agent-lab
pom.xml ------ 完整内容,可直接复制(版本组合已实测通过):
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.company</groupId>
<artifactId>ecom-agent-lab</artifactId>
<version>0.1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<langchain4j.version>1.22.0</langchain4j.version>
<jackson.version>2.17.2</jackson.version>
<slf4j.version>2.0.18</slf4j.version>
</properties>
<dependencies>
<!-- LangChain4j 的 OpenAI 协议实现:公司网关只要是 OpenAI 兼容就能用 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- 显式声明 HTTP 客户端实现,否则编译期拿不到 JdkHttpClientBuilder -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client-jdk</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- 日志门面:不加会持续输出 SLF4J(W) No providers were found 警告 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.2.0</version>
</plugin>
</plugins>
</build>
</project>
三个真实的坑(我实测踩到的,你一定会踩)
坑 1:langchain4j-http-client-jdk 必须显式声明。
langchain4j-open-ai 把它作为 runtime 依赖带进来------运行时能用,但编译期看不见 JdkHttpClientBuilder,代码直接报"程序包不存在"。我实测就是这么失败的。要么显式加这个依赖(上面已加),要么干脆不自己配 HTTP 客户端、只用 .timeout()。W1 建议加,因为 W2 你要分开配连接超时和读超时。
坑 2:mvn compile 报"不支持源选项 5"。
老版本 maven-compiler-plugin 默认 source/target 是 1.5 。<maven.compiler.release>17</maven.compiler.release> 在插件 3.6.0 以下不生效。所以 pom 里显式锁了 3.13.0。别省这一行。
坑 3:model.chat("字符串") 返回的是 String,不是 ChatResponse。
这是最容易错的一处。ChatModel 有多个 chat 重载:
| 写法 | 返回 | 能否拿到 token |
|---|---|---|
chat("你好") |
String |
不能 |
chat(UserMessage.from("你好")) |
ChatResponse |
能 |
chat(ChatRequest.builder()...build()) |
ChatResponse |
能,且能带工具 |
官方 Get Started 页面用的是 model.chat("Say 'Hello World'") 并直接打印------那个版本你拿不到 token。只要你要记账,必须用带 UserMessage 或 ChatRequest 的重载。 我当时就是照着官方示例写的,编译直接报"String 无法转换为 ChatResponse"。
3. 跑通第一次调用(30 分钟)
src/main/java/com/company/lab/W1Hello.java:
java
package com.company.lab;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.output.TokenUsage;
import java.time.Duration;
/**
* W1 第一个可运行程序:调通网关,看清 token。
* 刻意不做封装、不做重试------这一周的目标是"有反馈",不是"有架构"。
*/
public final class W1Hello {
public static void main(String[] args) {
String baseUrl = requireEnv("LLM_BASE_URL");
String apiKey = requireEnv("LLM_API_KEY");
String modelName = requireEnv("LLM_MODEL");
ChatModel model = OpenAiChatModel.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.modelName(modelName)
.temperature(0.0)
.timeout(Duration.ofSeconds(60)) // 不写就用默认值,显式写才是工程习惯
.maxRetries(0) // 这周关掉自动重试,先看清失败长什么样
.logRequests(true) // 打印发出的 JSON,排查协议问题靠它
.logResponses(true) // 打印收到的 JSON,排查字段缺失靠它
.build();
long start = System.nanoTime();
ChatResponse response = model.chat(UserMessage.from("用一句话解释:电商售后里什么是运费险。"));
long costMs = (System.nanoTime() - start) / 1_000_000;
TokenUsage usage = response.tokenUsage();
System.out.println("=== 回复 ===");
System.out.println(response.aiMessage().text());
System.out.println("=== 用量 ===");
System.out.println("输入 token : " + usage.inputTokenCount());
System.out.println("输出 token : " + usage.outputTokenCount());
System.out.println("总计 token : " + usage.totalTokenCount());
System.out.println("耗时 : " + costMs + " ms");
}
private static String requireEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"环境变量 " + name + " 未设置。请按教程 1.2 节设置后重开终端。");
}
return value;
}
}
运行:
bash
mvn -q compile exec:java -Dexec.mainClass=com.company.lab.W1Hello
期望输出(模型回复与 token 数随网关不同而变化):
=== 回复 ===
运费险是买家退货时可获赔退货运费的保障服务,通常随订单赠送或单独购买。
=== 用量 ===
输入 token : 23
输出 token : 15
总计 token : 38
耗时 : 1284 ms
失败对照表
| 报错关键词 | 含义 | 处理 |
|---|---|---|
401 / Unauthorized |
Key 无效或没带上 | 检查环境变量名拼写;echo $env:LLM_API_KEY 是否为空 |
403 |
无权限访问该模型 | 找网关要模型白名单 |
404 |
路径不对 | 换 baseUrl 后缀:/v1 → 空 → /openai/v1 |
ConnectException / UnknownHost |
地址不通 | 内网?需要 VPN?端口对不对? |
RateLimitException (429) |
限流 | 正常现象,W2 专门处理 |
InvalidRequestException (400) |
请求体字段不对 | 打开 logRequests,看发出的 JSON |
| 200 但内容为空 | 网关用了非标准字段 | 抓原始响应,字段可能是 result 而不是 choices |
String 无法转换为 ChatResponse |
用错了 chat 重载 |
改成 chat(UserMessage.from(...)),见坑 3 |
4. 建立成本直觉(40 分钟,本周核心)
这一步比写代码重要。你要亲手算出钱。
4.1 拿到真实单价
问网关要输入单价 和输出单价 (单位"元/百万 token"或"元/千 token")。
技巧:直接问"每 100 万 token 多少钱",比自己研究计费文档快十倍。
4.2 成本计算工具
src/main/java/com/company/lab/CostMeter.java:
java
package com.company.lab;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 按"每百万 token 单价"计算单次调用成本。
* 用 BigDecimal 而不是 double:成本累加次数多了以后,浮点误差会让账对不上。
*/
public record CostMeter(BigDecimal inputPricePerMillion,
BigDecimal outputPricePerMillion) {
private static final BigDecimal MILLION = new BigDecimal("1000000");
public BigDecimal costOf(Integer inputTokens, Integer outputTokens) {
BigDecimal in = BigDecimal.valueOf(inputTokens == null ? 0 : inputTokens);
BigDecimal out = BigDecimal.valueOf(outputTokens == null ? 0 : outputTokens);
return in.multiply(inputPricePerMillion)
.add(out.multiply(outputPricePerMillion))
.divide(MILLION, 6, RoundingMode.HALF_UP);
}
/** 一万 token 大约多少钱,用于建立直觉 */
public BigDecimal costOfTenThousandTokens() {
return new BigDecimal("10000")
.multiply(inputPricePerMillion)
.divide(MILLION, 6, RoundingMode.HALF_UP);
}
}
4.3 跑 20 次并落表
src/main/java/com/company/lab/W1CostLab.java:
java
package com.company.lab;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.output.TokenUsage;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
/**
* 跑 20 条电商售后问题,把 token / 耗时 / 成本写成 CSV。
* 这份 CSV 就是 W1 的交付物,也是后面所有成本优化的基线。
*/
public final class W1CostLab {
// 用长短不一的问题,观察 token 与耗时的关系------这是建立直觉的关键
private static final List<String> QUESTIONS = List.of(
"退货要先申请还是先寄回?",
"我买的东西七天无理由还能退吗?已经拆封了。",
"运费险赔付多久到账?是退到余额还是原路退回?",
"订单号 SN202610200001 的快递到哪了?显示签收了但我没收到。",
"我买的鞋子尺码偏小,想换大一码,需要自己出运费吗?换货运费谁承担?",
"大促期间下单的,退货时间是不是从 7 天延长到 15 天了?",
"生鲜类商品坏了怎么赔?需要提供什么凭证?照片还是视频?",
"我的退款申请提交两天了还没到账,能查一下卡在哪个环节吗?",
"发票能补开吗?公司抬头需要填税号,之前的发票开错了。",
"会员折扣和优惠券能叠加使用吗?我下单时好像没享受到会员价。"
);
public static void main(String[] args) throws IOException {
String baseUrl = requireEnv("LLM_BASE_URL");
String apiKey = requireEnv("LLM_API_KEY");
String modelName = requireEnv("LLM_MODEL");
CostMeter meter = new CostMeter(
new BigDecimal(System.getenv().getOrDefault("LLM_PRICE_IN", "0")),
new BigDecimal(System.getenv().getOrDefault("LLM_PRICE_OUT", "0")));
ChatModel model = OpenAiChatModel.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.modelName(modelName)
.temperature(0.0)
.timeout(Duration.ofSeconds(60))
.maxRetries(0)
.build();
Path csv = Path.of("w1-cost-log.csv");
if (!Files.exists(csv)) {
Files.writeString(csv, "序号,时间,问题字符数,输入token,输出token,耗时ms,成本元\n",
StandardCharsets.UTF_8, StandardOpenOption.CREATE);
}
BigDecimal total = BigDecimal.ZERO;
long totalMs = 0;
int runs = 20;
for (int i = 1; i <= runs; i++) {
String question = QUESTIONS.get((i - 1) % QUESTIONS.size());
long start = System.nanoTime();
ChatResponse response = model.chat(UserMessage.from(question));
long ms = (System.nanoTime() - start) / 1_000_000;
TokenUsage usage = response.tokenUsage();
BigDecimal cost = meter.costOf(usage.inputTokenCount(), usage.outputTokenCount());
total = total.add(cost);
totalMs += ms;
String line = String.format("%d,%s,%d,%d,%d,%d,%s%n",
i, LocalDateTime.now(), question.length(),
usage.inputTokenCount(), usage.outputTokenCount(), ms, cost.toPlainString());
Files.writeString(csv, line, StandardCharsets.UTF_8, StandardOpenOption.APPEND);
System.out.printf("[%02d/%d] in=%s out=%s %dms cost=%s%n",
i, runs, usage.inputTokenCount(), usage.outputTokenCount(), ms, cost.toPlainString());
}
System.out.println("-----------------------------");
System.out.println("总成本 : " + total.toPlainString() + " 元");
System.out.println("平均单次: " + total.divide(BigDecimal.valueOf(runs), 6, RoundingMode.HALF_UP) + " 元");
System.out.println("平均耗时: " + (totalMs / runs) + " ms");
System.out.println("一万 token 约: " + meter.costOfTenThousandTokens().toPlainString() + " 元(仅按输入价)");
System.out.println("CSV 已写入: " + csv.toAbsolutePath());
}
private static String requireEnv(String name) {
String v = System.getenv(name);
if (v == null || v.isBlank()) {
throw new IllegalStateException("环境变量 " + name + " 未设置");
}
return v;
}
}
运行:
bash
mvn -q compile exec:java -Dexec.mainClass=com.company.lab.W1CostLab
实测输出形态(以下为我用假网关跑出来的真实结果,单价假设为 2 / 8 元每百万 token):
[19/20] in=57 out=23 1ms cost=0.000298
[20/20] in=58 out=26 1ms cost=0.000324
-----------------------------
总成本 : 0.006672 元
平均单次: 0.000334 元
平均耗时: 12 ms
一万 token 约: 0.020000 元(仅按输入价)
CSV 已写入: C:\...\ecom-agent-lab\w1-cost-log.csv
CSV 实际内容长这样:
序号,时间,问题字符数,输入token,输出token,耗时ms,成本元
1,2026-10-20T10:02:38.112314,12,52,49,221,0.000496
2,2026-10-20T10:02:38.119295800,21,55,18,2,0.000254
你的真实网关跑出来的耗时会远大于 12ms(假网关不耗时)。不要拿我的数字当结论,你跑出来的才是真的。
4.4 从 CSV 里读出三件事
拿到表后回答这三个问题,写进周记:
- 输入 token 与问题字符数的比值是多少? 中文大致 1 字 ≈ 1~1.5 token。这个比值决定你以后怎么估上下文预算。
- 输出 token 波动大不大? 波动大 = 回答长度不可控,这就是后面要加
maxTokens和输出格式约束的原因。 - 耗时和输出 token 是否强相关? 通常强相关。这说明延迟优化的重点往往在"让模型少说废话",而不是"换更快模型"。
5. 一分钱不花的自测方式(推荐先跑这个)
在拿到真实网关令牌之前,你可以用本地假网关验证整条链路。工程里已经带了 FakeGateway.java 和 selfcheck-w1.sh。
bash
# 1. 编译
mvn -q compile
# 2. 生成依赖 classpath
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
# 3. 一键自测(启动假网关 -> 跑两个程序 -> 校验 CSV 行数 -> 关网关)
bash selfcheck-w1.sh
期望输出尾部:
== 4. 校验 CSV ==
数据行数 = 20 (期望 20)
序号,时间,问题字符数,输入token,输出token,耗时ms,成本元
1,2026-10-20T10:02:38.112314,12,52,49,221,0.000496
这个脚本的价值:把"代码写错了"和"网关不通"两类问题分开。如果假网关能跑通、真网关跑不通,那问题一定在鉴权、路径或协议上,不在你的代码里。
FakeGateway.java 只做一件事:在本机 18081 端口起一个假的 /v1/chat/completions,返回标准 OpenAI 格式响应(含 usage 字段)。它不联网、不花钱。
6. 常见问题
Q1:网关只给了一个模型名,但没有 OpenAI 兼容接口怎么办?
等 W2 的自封装方案。W1 先做环境变量、成本表结构设计、以及假网关链路验证。不要写临时 hack 客户端------W2 会给你完整版本。
Q2:logRequests(true) 把 API Key 打出来了怎么办?
LangChain4j 默认对 Authorization 头做脱敏,但不要依赖它。排查完协议问题就关掉。要长期开就自己外面包一层脱敏日志。
Q3:跑 20 次要花多少钱?
按国内主流模型量级,20 条短问答通常在几分钱到一毛钱之间。这点钱不值得省。真贵的是你为省几毛钱折腾免费额度浪费的两小时。
Q4:模型回答很啰嗦,一跑就几百 token,正常吗?
正常。20 条问题里有长有短,才能看出输出长度分布。W2 会加 maxTokens 上限。
Q5:公司不让用外部模型 API 怎么办?
那就只用公司网关------这正是选它的理由:数据不出内网。练习数据自己编(如 SN202610200001),不要用真实订单。
Q6:JDK 17 和公司老项目的 JDK 8 冲突怎么办?
两个 JDK 可以共存。用 JAVA_HOME 切换,或给这个工程单独配 IDEA 的 Project SDK。不要为了练手去升级生产项目。
7. 本周自检清单
按顺序打勾,全勾完才算 W1 通过:
-
java -version显示 17+ - 五个环境变量都设置了,
echo能看到值 - curl 探测网关返回 200,已判断出是形态 A/B/C
-
bash selfcheck-w1.sh跑通,数据行数 = 20 - 真实网关
W1Hello跑通,看到了回复 + token + 耗时 - 真实网关
W1CostLab跑完 20 次,生成了w1-cost-log.csv - 能口头回答:一万 token 多少钱、单次平均耗时多少
- 代码里搜不到 API Key 字面量(
grep -r "sk-" .) - 周记写了 3 条结论(token 比值、输出波动、耗时相关性)
没完成的怎么办: 剩 1--2 项放下周两小时轻任务补。剩 3 项以上,用一个机动周重做 W1------地基不牢,W2 的超时重试会让你更痛苦。
8. 三条待核实项(不要照抄,去查证)
| 项 | 为什么不确定 | 怎么核实 |
|---|---|---|
| 公司网关的鉴权方式与可用模型名 | 每家公司不同,我无法知道 | 问平台组,或看内部接入文档 |
| 模型单价(元/百万 token) | 随厂商、模型、时段变动 | 网关控制台或计费页当日价格 |
| 网关是否严格兼容 OpenAI 协议 | 部分自研网关字段有差异 | 用 1.3 节 curl 实测 |
另:本文所有 LangChain4j API 均以 1.22.0 实测为准(Registry 显示 1.22.0 发布于 2026-10-09,是当前最新稳定版)。框架迭代快,若你读到本文时版本已升,请以官方 Get Started 与 Javadoc 为准重新核对 chat 重载与 TokenUsage 方法名。
9. 下一周预告
W2 把"能跑的代码"变成"能扛事的代码":
- 把
.timeout()拆成连接超时 + 读超时,理解为什么两个都要设 - 自己实现退避重试(带抖动),搞清为什么内置
maxRetries不够用 - 错误分三类:可重试(429/5xx/超时)、需人工(400/401)、终止(内容违规)
- 输出 JSON 结构化日志,为 W18 的 trace 打底
- 用假网关注入故障(429 / 超时 / 断连),验证你的重试真的生效
带一个能跑的程序来 W2,不要空手来。