1. 从本地 Skills 到统一通道:Java 开发者踩过的第一个坑
LangChain4j 10 引入的 Skills 机制,本质上是给大模型配了一本"按需翻阅的操作手册"。它把技能拆成三层:L1 元数据层只放技能名和描述,初始注入系统消息,几十个技能也不会撑爆上下文;L2 核心指令层是 SKILL.md 里的详细步骤,只有模型调用activate_skill时才加载;L3 补充资源层是 references 目录下的参考文档,模型调用read_skill_resource时才读取。这套渐进式披露的设计,让 Skills 在多模型切换场景下特别实用——你可以把同一套技能定义挂到不同模型上,行为保持一致。
但真正落地时,问题往往不在 Skills 本身,而在模型接入层。我见过太多项目把 endpoint、API Key、模型名硬编码在application.yml里,本地跑得好好的,一换模型就报 401,或者local proxy failed。更麻烦的是,团队里每个人用的模型供应商不同,Skills 的调用链路没法复现,排查问题时连"到底请求发到了哪里"都说不清。
这篇内容面向需要在多模型间切换的 Java 开发者,给出 LangChain4j 10 Skills 的注册与调用配置片段,并演示把 endpoint 与鉴权统一改到 TaoToken 通道后的连通性验证动作。目标很明确:让 Skills 调用链路可复现、可排查。你不需要改 Skills 的业务逻辑,只需要把模型接入层抽出来,换成一个统一的 Base URL 和 Key,剩下的交给配置。
先说清楚 TaoToken 在这里扮演什么角色。它是一个统一的模型 API 通道,提供兼容 OpenAI 协议的接口,Base URL 是https://taotoken.net/api,你拿一个 Key 就能访问多个模型。对 LangChain4j 来说,这意味着你不需要为每个模型供应商写一套ChatModel配置,只需要改baseUrl和apiKey两个参数。Skills 的toolProvider、activate_skill、read_skill_resource这些机制完全不受影响,因为它们工作在 AiServices 层,跟底层模型通道解耦。
适合谁看:正在用 LangChain4j 1.12.x 做 Agent 或 Skills 落地的 Java 开发者;需要在 Claude、GPT、国产模型之间切换做对比测试的团队;被多套 Key 管理搞烦、想让调用链路可观测的工程师。如果你还在用 1.8.0 版本,Skills 相关 API 还没实装,建议先升到 1.12.2-beta22 再往下看。
2. TaoToken 前置:把模型通道从业务代码里抽出来
在动手改配置之前,先把 TaoToken 的接入信息准备好。你需要三样东西:Base URL、API Key、Model ID。Base URL 固定是https://taotoken.net/api,注意不要加 UTM 参数,那是给官网链接用的。API Key 在控制台的 API Keys 页面创建,创建后只显示一次,记得存到环境变量里,别写进代码。
Model ID 这块要留意,TaoToken 的模型名跟原生供应商可能不完全一样。比如你想用 Claude 系列,Model ID 可能是claude-sonnet-4-20250514这种格式;用 GPT 系列则是gpt-4o之类。具体以控制台模型列表为准。我建议先在模型对话页面手动发一条消息,确认模型能通,再往 Java 代码里接。这一步能省掉后面很多"到底是 Key 错了还是模型名错了"的排查时间。
环境变量这样设,Linux/macOS 下:
export TAOTOKEN_API_KEY="sk-你的实际Key" export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_MODEL_ID="claude-sonnet-4-20250514"Windows PowerShell:
$env:TAOTOKEN_API_KEY="sk-你的实际Key" $env:TAOTOKEN_BASE_URL="https://taotoken.net/api" $env:TAOTOKEN_MODEL_ID="claude-sonnet-4-20250514"为什么要用环境变量而不是直接写application.yml?因为 Skills 的调用链路排查时,你需要快速切换模型做对比。如果 Key 和 URL 写死在配置文件里,每次换模型都要改代码重新打包。用环境变量的话,改一下 shell 就能跑不同模型,Skills 的 SKILL.md 和工具注册逻辑完全不用动。
接下来是 Maven 依赖。LangChain4j 10 的 Skills 在 1.12.2-beta22 版本里,BOM 和 skills 模块都要引:
<dependencyManagement> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-bom</artifactId> <version>1.12.2-beta22</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-skills</artifactId> <version>1.12.2-beta22</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> </dependency> </dependencies>注意langchain4j-skills的版本要单独写,BOM 里可能还没纳入这个实验性模块。如果你用的是 Gradle,对应写法是implementation("dev.langchain4j:langchain4j-skills:1.12.2-beta22")。
Skills 的目录结构建议放在src/main/resources/skills/下,这样打包进 JAR 后也能通过 ClassPathSkillLoader 加载。不过 1.12.2-beta22 版本里 ClassPathSkillLoader 还没实装,所以现阶段还是用 FileSystemSkillLoader 从磁盘加载,目录放在项目根目录的skills/下:
skills/ ├── database-ops/ │ ├── SKILL.md │ └── references/ │ └── query-patterns.md └──>--- name: database-ops description: 数据库运维技能,根据用户意图选择合适的工具完成查询、表结构查看、数据变更 --- # 数据库运维技能 你是一个数据库运维助手。当用户请求涉及数据库操作时: 1. 先确认要操作的表名和字段 2. 查询类请求调用 executeQuery,变更类请求调用 executeUpdate 3. 执行变更前必须向用户确认影响范围这个文件就是 L2 核心指令层,只有模型调用activate_skill后才会加载。初始系统消息里只放 name 和 description,也就是 L1 元数据层。
3. 可复制配置:Skills 注册与 TaoToken 通道对接
这一节给出完整的配置片段,你可以直接复制到项目里改。核心思路是把ChatModel的构建抽成一个@Bean,Base URL 和 Key 从环境变量读,Skills 的加载和 AiServices 的组装分开写,方便单独测试。
先看ChatModel的配置。LangChain4j 的 OpenAI 兼容模式可以对接 TaoToken:
import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.chat.ChatModel; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.Duration; @Configuration public class ModelConfig { @Bean public ChatModel chatModel() { String baseUrl = System.getenv("TAOTOKEN_BASE_URL"); String apiKey = System.getenv("TAOTOKEN_API_KEY"); String modelId = System.getenv("TAOTOKEN_MODEL_ID"); if (baseUrl == null || apiKey == null || modelId == null) { throw new IllegalStateException( "缺少环境变量:TAOTOKEN_BASE_URL / TAOTOKEN_API_KEY / TAOTOKEN_MODEL_ID"); } return OpenAiChatModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelId) .timeout(Duration.ofSeconds(60)) .logRequests(true) .logResponses(true) .build(); } }这里logRequests和logResponses建议在排查阶段打开,能看到实际发出去的请求体和返回内容。确认链路通了之后再关掉,避免日志里出现敏感信息。
然后是 Skills 的加载。用FileSystemSkillLoader从skills/目录加载:
import dev.langchain4j.skills.Skills; import dev.langchain4j.skills.FileSystemSkillLoader; import dev.langchain4j.skills.FileSystemSkill; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.nio.file.Path; import java.util.List; @Configuration public class SkillsConfig { private static final String SKILLS_DIR = "skills"; @Bean public Skills skills() { List<FileSystemSkill> loaded = FileSystemSkillLoader.loadSkills(Path.of(SKILLS_DIR)); return Skills.from(loaded); } }Skills.from()返回的Skills对象提供了toolProvider()方法,这个方法会注册两个内置工具:activate_skill和read_skill_resource。前者让模型激活技能,后者让模型读取技能关联的资源文件。
接下来是 AiServices 的组装。这里要注意,1.12.2 版本的AiServices只能指定一个ToolProvider,所以如果你同时有 Skills 工具和 MCP 工具,需要写一个组合器:
import dev.langchain4j.service.AiServices; import dev.langchain4j.service.tool.ToolProvider; import dev.langchain4j.service.tool.ToolProviderRequest; import dev.langchain4j.service.tool.ToolProviderResult; import dev.langchain4j.skills.Skills; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @Configuration public class AssistantConfig { @Bean public SkillsAssistant skillsAssistant(ChatModel chatModel, Skills skills) { ToolProvider composite = CompositeToolProvider.of( skills.toolProvider() // 如果有 MCP 工具,在这里追加:, mcpToolProvider ); return AiServices.builder(SkillsAssistant.class) .chatModel(chatModel) .toolProvider(composite) .systemMessage(buildSystemMessage(skills)) .build(); } private String buildSystemMessage(Skills skills) { return """ 你是一个智能助手。你可以使用以下技能: %s 当用户的请求涉及某个技能时,请先使用 activate_skill 工具激活对应技能, 然后按照技能指令中的策略完成任务。 """.formatted(skills.formatAvailableSkills()); } }skills.formatAvailableSkills()会生成一段 XML 格式的技能列表,包含每个技能的 name 和 description。这段 XML 就是 L1 元数据层,初始注入系统消息,让模型知道有哪些技能可用。
CompositeToolProvider的实现不复杂,核心是把多个ToolProvider的工具合并:
import dev.langchain4j.service.tool.ToolProvider; import dev.langchain4j.service.tool.ToolProviderRequest; import dev.langchain4j.service.tool.ToolProviderResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; public class CompositeToolProvider implements ToolProvider { private static final Logger log = LoggerFactory.getLogger(CompositeToolProvider.class); private final List<ToolProvider> delegates; private CompositeToolProvider(List<ToolProvider> delegates) { this.delegates = Collections.unmodifiableList(delegates); } public static CompositeToolProvider of(ToolProvider... providers) { if (providers == null || providers.length == 0) { throw new IllegalArgumentException("至少需要传入一个 ToolProvider"); } return new CompositeToolProvider(Arrays.asList(providers)); } @Override public ToolProviderResult provideTools(ToolProviderRequest request) { ToolProviderResult.Builder builder = ToolProviderResult.builder(); Set<String> mergedImmediateReturn = new HashSet<>(); for (ToolProvider delegate : delegates) { try { ToolProviderResult result = delegate.provideTools(request); if (result == null) { continue; } if (result.tools() != null && !result.tools().isEmpty()) { builder.addAll(result.tools()); log.debug("从 {} 收集到 {} 个工具", delegate.getClass().getSimpleName(), result.tools().size()); } if (result.immediateReturnToolNames() != null) { mergedImmediateReturn.addAll(result.immediateReturnToolNames()); } } catch (Exception e) { log.error("从 {} 收集工具时异常,已跳过:{}", delegate.getClass().getSimpleName(), e.getMessage(), e); } } if (!mergedImmediateReturn.isEmpty()) { builder.immediateReturnToolNames(mergedImmediateReturn); } return builder.build(); } }immediateReturnToolNames这个集合的作用是:当模型调用了集合里的工具后,工具结果直接返回给用户,不再送回模型做二次处理。Skills 的activate_skill通常不需要立即返回,但如果你有查询类工具希望直接出结果,可以把它加进去。
最后是 Assistant 接口的定义:
import dev.langchain4j.service.SystemMessage; public interface SkillsAssistant { String chat(String userMessage); }到这里,配置部分就齐了。整个链路是:环境变量提供 TaoToken 的 Base URL、Key、Model ID →ChatModelBean 用这些参数构建 →SkillsBean 从磁盘加载 SKILL.md →AiServices把ChatModel、Skills.toolProvider()、系统消息组装成 Assistant。Skills 的业务逻辑跟模型通道完全解耦,换模型只需要改环境变量。
4. 验证请求:从启动日志到 Skills 激活的完整链路
配置写完之后,别急着写业务代码,先做连通性验证。这一步的目的是确认三件事:TaoToken 通道能通、Skills 能被加载、模型能正确调用activate_skill。
第一步,写一个最小的启动类,打印 Skills 加载结果和系统消息:
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class SkillsDemoApplication { public static void main(String[] args) { SpringApplication.run(SkillsDemoApplication.class, args); } @Bean CommandLineRunner verify(Skills skills, SkillsAssistant assistant) { return args -> { System.out.println("=== 已加载技能 ==="); System.out.println(skills.formatAvailableSkills()); System.out.println("=== 发起测试请求 ==="); String reply = assistant.chat("帮我查一下 demo 库里 data_demo 表的数据"); System.out.println("=== 模型回复 ==="); System.out.println(reply); }; } }启动应用,观察控制台输出。正常情况下你会看到两段关键日志。
第一段是 Skills 加载结果,格式类似:
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <available_skills> <skill> <name>database-ops</name> <description>数据库运维技能,根据用户意图选择合适的工具完成查询、表结构查看、数据变更</description> </skill> </available_skills>如果这段 XML 是空的,说明FileSystemSkillLoader.loadSkills()没找到 SKILL.md。检查skills/目录是否在项目根目录下,SKILL.md 的 YAML front matter 是否有name和description字段。
第二段是模型调用日志。因为开了logRequests(true),你能看到实际发往 TaoToken 的请求体。重点看三个字段:model是不是你设的 Model ID,messages里有没有包含<available_skills>的 XML,tools里有没有activate_skill和read_skill_resource。
如果请求发出去了但返回 401,说明 Key 有问题。检查环境变量TAOTOKEN_API_KEY是否设置正确,注意不要有多余的空格或换行。如果返回 404,大概率是 Base URL 写错了,确认是https://taotoken.net/api而不是别的路径。
模型返回的内容里,你应该能看到它先调用了activate_skill,参数是{"skill_name":"database-ops"},然后根据 SKILL.md 里的指令继续执行。如果模型直接回答了问题而没有激活技能,说明系统消息里的技能描述不够明确,或者模型没理解activate_skill的用途。可以在系统消息里加一句"必须先激活技能再执行任务"来强化。
为了更清楚地看到工具调用链路,可以注册一个ToolExecutedEventListener:
import dev.langchain4j.service.tool.ToolExecutedEvent; import dev.langchain4j.service.tool.ToolExecutedEventListener; ToolExecutedEventListener listener = event -> { String toolName = event.request().name(); String arguments = event.request().arguments(); if ("activate_skill".equals(toolName)) { System.out.println("【Skills 激活】参数:" + arguments); } else if ("read_skill_resource".equals(toolName)) { System.out.println("【Skills 读资源】参数:" + arguments); } else { System.out.println("【工具调用】" + toolName + " 参数:" + arguments); } System.out.println("【执行结果】" + event.resultText()); };把这个 listener 通过.registerListener(listener)注册到 AiServices 上,就能在控制台看到完整的调用链路:模型先激活技能,再按技能指令调用具体工具,最后返回结果。这条链路可复现、可观测,排查问题时不用猜。
验证通过后,你可以试着改环境变量里的TAOTOKEN_MODEL_ID,换成另一个模型,重启应用再跑一次。Skills 的 SKILL.md 和工具注册逻辑完全不用动,模型依然能正确激活技能并执行。这就是统一通道的价值——模型可替换,技能定义保持稳定。
5. 本篇常见错排查:401、local proxy failed 与 choices 解析失败
这一节整理几个实际踩过的坑,都是跟 TaoToken 通道和 Skills 集成相关的报错。每个报错给出原因和修复动作,你可以对照自己的日志排查。
报错一:401 Unauthorized
dev.langchain4j.exception.AuthenticationException: 401 Unauthorized原因通常是 API Key 没传对。检查三个地方:环境变量TAOTOKEN_API_KEY是否设置,值是否以sk-开头,有没有多余空格。如果你用的是 IDE 启动,注意 IDE 的环境变量配置可能跟 shell 不一样,需要在 Run Configuration 里单独设。另外,Key 如果是在控制台刚创建的,确认没有复制错行。
报错二:local proxy failed / Connection refused
java.net.ConnectException: Connection refused dev.langchain4j.exception.LangChain4jException: local proxy failed这个报错说明请求根本没发出去,卡在本地网络层。检查TAOTOKEN_BASE_URL是否写成了https://taotoken.net/api,注意结尾不要加/v1或/chat/completions,LangChain4j 的 OpenAI 客户端会自动拼路径。如果你在公司内网,确认网络策略允许访问外部 HTTPS 接口。还有一种情况是代理配置冲突,检查 JVM 启动参数里有没有-Dhttp.proxyHost之类的设置,有的话先去掉。
报错三:reading choices / Cannot deserialize
com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.util.ArrayList` from Object value at [Source: (String)"..."; line: 1, column: 1] (through reference chain: dev.langchain4j.model.openai.internal.chat.ChatCompletionResponse["choices"])这个报错说明返回的 JSON 结构跟 LangChain4j 期望的不一致。常见原因是 Base URL 指向了一个返回非标准 OpenAI 格式的接口。确认TAOTOKEN_BASE_URL是https://taotoken.net/api,不要指向其他路径。如果确认 URL 没问题,打开logResponses(true)看实际返回的 JSON,检查choices字段是否存在且是数组格式。
报错四:OAuth / token expired
dev.langchain4j.exception.AuthenticationException: OAuth token expired如果你用的是 Codex 的auth.json或者 Claude Code 的 OAuth 凭证,这类报错说明凭证过期了。TaoToken 用的是 API Key 鉴权,不涉及 OAuth 流程,所以如果你在代码里混用了 OAuth 凭证,需要改成 API Key。检查ChatModel构建时用的是.apiKey()而不是.credentials()之类的方法。
报错五:Skills 未激活,模型直接回答
日志里看不到activate_skill调用,模型直接给了答案。原因可能是系统消息里的技能描述太模糊,模型没意识到需要用技能。修复方法是在系统消息里明确写"当用户请求涉及以下技能时,必须先调用 activate_skill 激活"。另外确认skills.formatAvailableSkills()的输出确实包含在系统消息里,有时候字符串拼接出错会导致 XML 没传进去。
报错六:read_skill_resource 找不到文件
Skill resource not found: references/query-patterns.md检查skills/database-ops/references/目录下是否真的有这个文件。注意FileSystemSkillLoader加载资源时,路径是相对于技能目录的,不要写成绝对路径。另外scripts/目录下的文件不会被当作资源加载,这是设计如此,脚本需要通过工具调用来执行。
排查时有个通用技巧:把logRequests和logResponses都打开,先看请求体里model、messages、tools三个字段是否符合预期,再看响应体里choices[0].message的内容。大部分问题在请求体里就能定位——要么是 Key 没传,要么是模型名写错,要么是系统消息没拼对。
6. 把 Skills 调用链路固定下来
走到这里,你应该已经跑通了从 TaoToken 通道到 LangChain4j Skills 的完整链路。回顾一下关键动作:环境变量提供 Base URL、Key、Model ID;ChatModelBean 用这些参数构建;SkillsBean 从磁盘加载 SKILL.md;AiServices把两者组装起来,系统消息里注入 L1 元数据;模型按需调用activate_skill加载 L2 指令,按需调用read_skill_resource读取 L3 资源。
这套配置的价值在于可复现。团队里任何人拉下代码,设好三个环境变量,就能跑出一样的 Skills 调用链路。换模型只需要改TAOTOKEN_MODEL_ID,SKILL.md 和工具注册逻辑不动。排查问题时,打开请求和响应日志,能看到模型实际收到了哪些技能描述、调用了哪些工具、返回了什么结果。
如果你要长期做编码类 Agent,建议把 Skills 和 Coding Plan 结合使用,让模型在长会话里保持技能上下文。如果只是验证某个模型对 Skills 的支持情况,可以先用模型对话页面手动测一轮,确认模型能正确理解activate_skill的语义,再往 Java 代码里接。接入过程中遇到鉴权或通道问题,对照 API Keys 页面检查 Key 状态,接入文档里有各语言的配置示例。
最后留一个实用技巧:把skills/目录纳入 Git 管理,SKILL.md 的每次修改都有记录。模型通道的配置用环境变量隔离,不提交到仓库。这样技能定义的演进和模型通道的切换互不干扰,排查问题时也能快速定位是技能逻辑变了还是通道配置变了。