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_KEYsk-你的实际Key export TAOTOKEN_BASE_URLhttps://taotoken.net/api export TAOTOKEN_MODEL_IDclaude-sonnet-4-20250514Windows PowerShell$env:TAOTOKEN_API_KEYsk-你的实际Key $env:TAOTOKEN_BASE_URLhttps://taotoken.net/api $env:TAOTOKEN_MODEL_IDclaude-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 groupIddev.langchain4j/groupId artifactIdlangchain4j-bom/artifactId version1.12.2-beta22/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-skills/artifactId version1.12.2-beta22/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-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的构建抽成一个BeanBase URL 和 Key 从环境变量读Skills 的加载和 AiServices 的组装分开写方便单独测试。先看ChatModel的配置。LangChain4j 的 OpenAI 兼容模式可以对接 TaoTokenimport 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() { ListFileSystemSkill 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 ListToolProvider delegates; private CompositeToolProvider(ListToolProvider 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(); SetString 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 version1.0 encodingUTF-8 standaloneno? available_skills skill namedatabase-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 IDmessages里有没有包含available_skills的 XMLtools里有没有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的用途。可以在系统消息里加一句必须先激活技能再执行任务来强化。为了更清楚地看到工具调用链路可以注册一个ToolExecutedEventListenerimport 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 Unauthorizeddev.langchain4j.exception.AuthenticationException: 401 Unauthorized原因通常是 API Key 没传对。检查三个地方环境变量TAOTOKEN_API_KEY是否设置值是否以sk-开头有没有多余空格。如果你用的是 IDE 启动注意 IDE 的环境变量配置可能跟 shell 不一样需要在 Run Configuration 里单独设。另外Key 如果是在控制台刚创建的确认没有复制错行。报错二local proxy failed / Connection refusedjava.net.ConnectException: Connection refused dev.langchain4j.exception.LangChain4jException: local proxy failed这个报错说明请求根本没发出去卡在本地网络层。检查TAOTOKEN_BASE_URL是否写成了https://taotoken.net/api注意结尾不要加/v1或/chat/completionsLangChain4j 的 OpenAI 客户端会自动拼路径。如果你在公司内网确认网络策略允许访问外部 HTTPS 接口。还有一种情况是代理配置冲突检查 JVM 启动参数里有没有-Dhttp.proxyHost之类的设置有的话先去掉。报错三reading choices / Cannot deserializecom.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 expireddev.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 IDChatModelBean 用这些参数构建SkillsBean 从磁盘加载 SKILL.mdAiServices把两者组装起来系统消息里注入 L1 元数据模型按需调用activate_skill加载 L2 指令按需调用read_skill_resource读取 L3 资源。这套配置的价值在于可复现。团队里任何人拉下代码设好三个环境变量就能跑出一样的 Skills 调用链路。换模型只需要改TAOTOKEN_MODEL_IDSKILL.md 和工具注册逻辑不动。排查问题时打开请求和响应日志能看到模型实际收到了哪些技能描述、调用了哪些工具、返回了什么结果。如果你要长期做编码类 Agent建议把 Skills 和 Coding Plan 结合使用让模型在长会话里保持技能上下文。如果只是验证某个模型对 Skills 的支持情况可以先用模型对话页面手动测一轮确认模型能正确理解activate_skill的语义再往 Java 代码里接。接入过程中遇到鉴权或通道问题对照 API Keys 页面检查 Key 状态接入文档里有各语言的配置示例。最后留一个实用技巧把skills/目录纳入 Git 管理SKILL.md 的每次修改都有记录。模型通道的配置用环境变量隔离不提交到仓库。这样技能定义的演进和模型通道的切换互不干扰排查问题时也能快速定位是技能逻辑变了还是通道配置变了。