GitHub Copilot SDK for Java 企业级 Agent Harness 实战:注解式工具 + 虚拟线程 + BYOK 三件套,让 Java 工程师写 Agent 不再是异类
1. 为什么 Java 工程师写 Agent 总感觉像异类如果你是一名写了五六年 Java 的后端最近被要求“搞个 Agent 出来”大概率经历过这种别扭团队里 Python 同事三行代码接上模型你这边要先决定绑 Spring AI 还是 LangChain4j绑完发现项目是 Quarkus于是开始怀疑人生。GitHub Copilot SDK for Java 想解决的正是这件事——它把 Agent 能力做成一套只依赖 JDK 原生特性的库注解声明工具、虚拟线程跑并发、BYOK 自带密钥接模型不强制你换框架。这篇面向的是已经会用 Maven、懂基本并发、但没写过 Agent 的 Java 工程师。我会用一个“客户线索资格判定 Agent Harness”的完整例子把注解式工具、虚拟线程调度、BYOK 密钥接入这三件套串起来给出可复制的pom.xml、config.toml、settings.json骨架以及本地启动并验证工具调用链路的完整动作。读完你应该能跑通第一个企业级 Agent 原型而不是停在“Hello World 调通 API”那一步。先说清楚 Agent Harness 是什么。你可以把它理解成“给模型套的脚手架”模型负责决策Harness 负责把工具注册进去、把并发调度管起来、把密钥和模型路由配好、把每一步事件推出来。Java 工程师熟悉的 Servlet 容器、线程池、依赖注入在这里都能找到对应物。区别只是这次被编排的是 LLM 的推理步骤而不是 HTTP 请求。2. 前置准备TaoToken 统一 Key 与 API 通道在写代码之前先把模型接入这条链路理顺。企业里最烦的往往不是代码而是“每个模型一个 Key、每个 Key 一套计费、每个环境一份配置”。我的做法是用 TaoToken 做统一入口一个 Key 走 OpenAI 兼容协议模型切换只改配置不改代码。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。你需要先拿到一个可用的 Key。登录后进入控制台在 API Keys 页面创建一个建议按环境命名比如lead-agent-dev方便后面排查是哪套环境在消耗额度。创建入口在这里https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。拿到形如sk-开头的字符串后不要硬编码进代码后面我们用环境变量注入。这里要强调 BYOK 的意义。Copilot SDK for Java 名字里带 Copilot但它支持自定义baseUrlapiKey也就是 Bring Your Own Key。你完全不需要 GitHub Copilot 订阅把baseUrl指向 TaoToken 的兼容端点wireApi设为openai就能用同一套代码对接不同模型。对国内团队来说这解决了数据合规和计费统一两个现实问题。如果你只是想先验证模型通不通不想马上写 Java可以直接在模型对话页面发一条消息试试https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。确认 Key 有效、余额正常再进入编码环节能省掉很多“到底是网络问题还是代码问题”的扯皮。3. 可复制配置pom.xml、config.toml 与 settings.json3.1 pom.xml 骨架Copilot SDK for Java 的注解处理器目前是实验性功能必须在编译期显式打开开关否则CopilotTool会被静默跳过运行时模型看不到任何工具。这是新手最容易踩的坑先把它配好。properties maven.compiler.release25/maven.compiler.release copilot.sdk.version1.0.7-preview.1/copilot.sdk.version /properties dependencies dependency groupIdcom.github/groupId artifactIdcopilot-sdk-java/artifactId version${copilot.sdk.version}/version /dependency /dependencies build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.15.0/version configuration release25/release compilerArgs arg-Acopilot.experimental.allowedtrue/arg /compilerArgs annotationProcessorPaths path groupIdcom.github/groupId artifactIdcopilot-sdk-java/artifactId version${copilot.sdk.version}/version /path /annotationProcessorPaths /configuration /plugin /plugins /build两个关键点-Acopilot.experimental.allowedtrue是实验性 API 开关annotationProcessorPaths必须把 SDK 本身加进去编译器才能找到注解处理器。编译目标设成 25是因为 SDK 是 MR-JAR运行在 JDK 25 上会自动启用虚拟线程实现写的是兼容代码跑的是并发红利。3.2 config.toml 骨架Copilot CLI 子进程负责实际的模型路由和工具执行它的配置放在config.toml。SDK 通过 JSON-RPC over stdio 和这个子进程通信所以这份配置决定了模型走哪个端点。# ~/.config/copilot/config.toml [provider] base_url https://taotoken.net/api api_key ${TAOTOKEN_API_KEY} wire_api openai [model] default claude-sonnet-4.5 timeout_seconds 60 [permissions] mode approve_allbase_url指向 TaoToken 的 API 端点api_key用环境变量占位避免明文落盘。wire_api openai表示走 OpenAI 兼容协议这是 TaoToken 支持的接入方式。timeout_seconds一定要设后面排障章节会讲为什么。3.3 settings.json 骨架有些团队习惯把运行时开关放在settings.jsonSDK 也支持从项目根目录读取。它和config.toml的分工是config.toml管模型和密钥settings.json管会话行为和工具可见性。{ session: { systemMessageMode: customize, maxToolRounds: 12, streamAssistantMessages: true }, tools: { scanPackages: [com.example.leads.tools], overridesBuiltIn: false }, observability: { logToolInvocations: true, logSessionId: true } }systemMessageMode设为customize而不是replace意思是只替换你指定的系统消息片段保留 CLI 默认的格式约束避免模型不按 JSON Schema 输出。scanPackages告诉 SDK 去哪些包扫描注解工具logToolInvocations打开后每次工具调用都会打日志调试阶段非常有用。4. 注解式工具 虚拟线程把工具声明写成像 JAX-RS4.1 一个注解就是一个工具Copilot SDK for Java 最舒服的地方是工具声明方式和你写 REST 接口几乎一样。CopilotTool标在方法上CopilotToolParam标在参数上注解处理器在编译期生成工具元数据运行时ToolDefinition.fromObject(instance)一行注册整个类的工具。package com.example.leads.tools; import com.github.copilot.tool.CopilotTool; import com.github.copilot.tool.CopilotToolParam; import com.github.copilot.rpc.ToolInvocation; public class PropertyDatabase { public record Property(String id, String type, String city, int bedrooms, double priceGbp) {} CopilotTool( value Searches the real estate listings database, returns up to 10 matches., name search_properties) public ListProperty searchProperties( CopilotToolParam(Property type substring, e.g. flat or house) String type, CopilotToolParam(City substring, e.g. London) String city, CopilotToolParam(Minimum bedrooms, 0 for no minimum) int minBedrooms, CopilotToolParam(Maximum price in GBP, 0 for no maximum) double maxPriceGbp, ToolInvocation invocation) { // invocation 自动注入不出现在给模型的 schema 里 String sessionId invocation.getSessionId(); return queryFromDb(type, city, minBedrooms, maxPriceGbp, sessionId); } }注意ToolInvocation这个参数。它不会暴露给模型SDK 在调用时自动注入你能在里面拿到sessionId。企业场景里这个太有用了把sessionId当 traceId 关联到 APM或者从里面取租户上下文不用在每个工具方法签名里手动传tenantId。建议把它放在参数列表最后实测下来位置靠后最稳。4.2 虚拟线程调度为什么推荐 JDK 25SDK 内部有个ExecutorFactory运行在 JDK 25 及以上时自动返回Executors.newVirtualThreadPerTaskExecutor()否则退回缓存线程池。这意味着你写的是普通阻塞代码跑起来每个 Agent 会话占一个虚拟线程几百个并发会话也不会把平台线程打满。但有个前提如果你在 Jakarta EE 容器里跑不能直接用Thread.startVirtualThread()因为虚拟线程不携带容器的 ThreadContext调用Inject的 Bean 或PersistenceContext的 EntityManager 会抛ContextNotActiveException。正确做法是注入容器管理的ManagedThreadFactoryApplicationScoped public class LeadOrchestrator { Resource private ManagedThreadFactory threadFactory; public void process(String enquiry) { threadFactory.newThread(() - { try (var client new CopilotClient()) { client.start().get(); // ... 会话逻辑 } }).start(); } }这样虚拟线程由容器创建CDI 上下文能正确传播JPA 也能正常用。如果你的项目还在 JDK 17/21并发量超过两百左右可能触发OutOfMemoryError: unable to create new native thread建议至少升到 JDK 21能上 25 最好。4.3 系统消息约束 Agent 行为边界工具注册好了还得告诉模型什么时候用哪个。SystemMessageConfig的CUSTOMIZE模式允许你只替换指定片段SystemMessageConfig systemMessage new SystemMessageConfig() .setMode(SystemMessageMode.CUSTOMIZE) .setSections(Map.of( guidelines, You are a real estate lead qualification agent. 1. Always call set_current_phase first. 2. If the enquiry is not about real estate, call set_current_phase REJECTED_GARBAGE. 3. Otherwise call search_properties with extracted filters. 4. If no properties found, call set_current_phase REJECTED_NO_MATCHES. 5. Otherwise call calc_mortgage for the best 3 matches. 6. Finally call set_current_phase DONE. Do NOT call any tools not in the list. ));这段约束把 Agent 的行为收敛成一条明确的流水线避免模型自由发挥去调用不存在的工具。企业级 Harness 的核心价值就在这里不是让模型随便玩而是给它划好轨道。5. 验证请求本地启动并跑通工具调用链路5.1 组装会话并发送请求把前面的工具、系统消息、BYOK 配置组装起来就是一个最小可用的 Harnesstry (var client new CopilotClient()) { client.start().get(); ListToolDefinition tools new ArrayList(); tools.addAll(ToolDefinition.fromObject(progressTools)); tools.addAll(ToolDefinition.fromObject(propertyDb)); tools.addAll(ToolDefinition.fromObject(mortgageCalc)); SessionConfig config new SessionConfig() .setModel(claude-sonnet-4.5) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSystemMessage(systemMessage) .setTools(tools); var session client.createSession(config).get(); session.on(AssistantMessageEvent.class, msg - LOG.info(assistant: msg.getData().content())); session.on(SessionIdleEvent.class, idle - LOG.info(session idle, phase progressTools.getCurrentPhase())); session.send(new MessageOptions() .setPrompt(Looking for a 3 bedroom house in London under 800k)) .orTimeout(60, TimeUnit.SECONDS) .get(); }orTimeout这行别省。CLI 子进程如果因为网络或内存问题僵死父进程的 stdio 管道还在sendAndWait会永远阻塞加超时是保命措施。5.2 期望看到的调用链路启动后日志里应该出现一条清晰的工具调用序列类似这样[phase] set_current_phase - VALIDATING [intent] report_intent - find 3BR London [phase] set_current_phase - SEARCHING [tool_call] search_properties(typehouse, cityLondon, minBedrooms3, maxPriceGbp800000) [tool_result] 4 properties returned [phase] set_current_phase - WRITING_REPORT [tool_call] calc_mortgage(principal750000, annualRate0.045, termYears25) [phase] set_current_phase - DONE如果你看到这条链路完整跑完说明注解工具被正确扫描、虚拟线程调度正常、BYOK 路由到了 TaoToken 端点、模型按系统消息约束执行。这就是一个能用的企业级 Agent 原型了。5.3 用模型对话页做交叉验证代码跑通后建议去模型对话页面用同样的 prompt 手动发一次对比模型在无工具情况下的回答。如果手动对话能答对但代码里工具没被调用问题多半在工具注册或系统消息如果两边都不对问题在模型或 Key。这个交叉验证能快速定位故障层https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。6. 本篇常见错误排查6.1 模型看不到任何工具最常见的原因是 Maven 没加-Acopilot.experimental.allowedtrue注解处理器静默跳过fromObject返回空列表。跑mvn clean compile -X看注解处理器有没有执行或者直接打印tools.size()确认。6.2 ContextNotActiveException在虚拟线程里调用 CDI Bean 报这个错说明你用了Thread.startVirtualThread()而不是容器管理的ManagedThreadFactory。换成注入方式即可见 4.2 节。6.3 请求 404 或 401404 通常是baseUrl路径不对TaoToken 的端点是https://taotoken.net/api不要多加或漏掉路径段。401 是 Key 无效或没注入检查环境变量TAOTOKEN_API_KEY是否在当前 shell 生效echo $TAOTOKEN_API_KEY确认一下。6.4 Agent 卡死不返回先看是不是没加orTimeout。如果加了超时还是卡检查 CLI 子进程是否僵死ps aux | grep copilot看看有没有残留进程。生产环境建议在超时回调里client.stop()重启 CLI。6.5 跨类扫描返回空如果ToolDefinition.fromObject(bean)返回空但手动ToolDefinition.from(...)能注册多半是 CDI 客户端代理隐藏了原始类上的注解。解决办法是用BeanManager拿 raw type或者改用 Lambda 包装器手动反射注册。6.6 工具 schema 膨胀注册三十多个工具后每次请求都把全部 JSON Schema 塞进 prompttoken 消耗翻倍。建议按会话拆分工具集或者用系统消息强制模型按需选择别一次性全注册。7. 下一步从原型到长期编码工作流跑通原型只是第一步。真正把 Agent 用起来你会需要更稳定的编码辅助和更长的会话管理。如果你打算把 Agent 能力接进日常开发流程比如让它在 IDE 里持续帮你写代码、跑测试、改 bug可以了解一下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。它面向的是长期编码和 Agent 场景和这篇讲的 Harness 是互补关系——Harness 负责编排Coding Plan 负责把模型能力稳定地喂给开发流程。接入细节和参数说明都在文档里遇到配置问题先查这里https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。如果你用的是 Claude Code 这类工具Anthropic 兼容接入的说明在这里https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite 。最后给个实操建议先把这篇的pom.xml和config.toml复制到你的项目里用 JDK 25 编译跑通那条工具调用链路。跑通之后把你现有的业务方法一个个加上CopilotTool注解逐步迁移。Java 工程师做 AI缺的从来不是能力而是一个不逼你换框架的入口。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →