
StreamApp2 这套基于 HTTP 构建 MCP Tools 的示例跑通之后再回头看真正让人返工的往往不是 chatLoop而是 AiConfig 里那一行 getOpenAiStreamingChatModel()。模型通道、Key、Base URL 全写死在 Java 代码里想换一条统一通道就得改源码、重新打包连测试环境都可能因为 Key 不同而行为不一致。这篇只做一件事把这条模型通道接到 TaoToken先去 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建 Key再把 baseUrl 填成 https://taotoken.net/apitools.json 的动态加载和 HttpToolExecutor 的 HTTP 执行逻辑一行都不动。整个 Agent Loop 依旧由 chatLoop 驱动ToolExecutionRequest 依旧由模型生成只是生成它的那次流式请求换了一条更省心的出口。1. StreamApp2 里的模型通道为什么必须从 AiConfig 里拆出来1.1 getOpenAiStreamingChatModel() 是换通道的唯一卡点原始工程的结构其实很干净StreamApp2 负责加载 tools.json、构建 ToolSpecification、进入 chatLoopHttpToolExecutor 负责把 ToolExecutionRequest 翻译成 GET 或 POSTOpenAiStreamingChatModel 负责跟大模型对话。问题出在第三层的初始化方式上——模型实例由 AiConfig.getOpenAiStreamingChatModel() 静态返回baseUrl、apiKey、modelName 三件事通常就写在这一个方法里。想换通道就得改这个方法然后重新编译。更麻烦的是 Key 的位置。项目小的时候Key 可能直接写在 Java 常量里稍微大一点可能散落在 application.yml、环境变量脚本、甚至某个只在本地跑的测试类里。等到要接第二条通道时你会发现真正的工作量不是填两个参数而是把所有引用点找齐。所以这次改动的原则很明确把模型通道的三个变量收口到 AiConfig其余代码保持原样chatLoop 和 HttpToolExecutor 完全不需要感知 Key 从哪来。1.2 tools.json 与 HttpToolExecutor 不该被牵连很多人一看要换模型供应商第一反应是连 tools.json 一起改。其实这两件事的职责是分开的tools.json 描述的是“有哪些 HTTP API 可以被调用”HttpToolExecutor 负责真正去发 GET/POST。它们打的是你自己业务系统的地址跟模型供应商没有任何关系。模型通道只负责一件事——把 messages 和 toolSpecifications 发出去把 ToolExecutionRequest 收回来。因此本章的边界是TaoToken 只出现在 AiConfig.getOpenAiStreamingChatModel() 的 baseUrl 和 apiKey 这两处。HttpToolExecutor 里的 OkHttpClient、doGet、doPost、executeRequest一行都不改。判断标准也很简单如果一次请求发出的是天气 API、订单 API、内部配置 API那一定是 HttpToolExecutor 干的如果发出的是 /chat/completions 这类模型对话请求那才是 TaoToken 在承载。两条链路在日志里应该能一眼分开。2. 把 Key、Base URL、模型 ID 收口到 AiConfig2.1 先在 TaoToken 创建一把 Key准备材料只有三样一个可用的模型 ID、一把 API Key、一个正确的 Base URL。打开 TaoToken 控制台 注册登录在控制台里创建 API Key复制出来的字符串先不要贴进聊天记录或提交到 Git代码里统一用 YOUR_API_KEY 占位。Key 的创建入口就在 控制台 API Keys后续如果要在多台机器上跑 StreamApp2建议每个环境单独创建一把出问题好排查。接下来确认两个容易填错的值。Base URL 填 https://taotoken.net/api末尾不要加 /v1也不要带任何查询参数模型 ID 不要凭印象写去模型广场看当前可用的列表以页面展示为准。把这两个值和 Key 一起放到环境变量里Java 代码只负责读取这样本地、测试、线上可以用同一份 AiConfig靠环境变量区分。export TAOTOKEN_BASE_URLhttps://taotoken.net/api export TAOTOKEN_API_KEYYOUR_API_KEY export TAOTOKEN_MODEL以模型广场当时列表为准的模型 ID2.2 重写 getOpenAiStreamingChatModel()AiConfig 的改造目标只有一句话把写死的通道信息换成可外部覆盖的读取逻辑同时保持返回类型仍然是 OpenAiStreamingChatModel这样 StreamApp2 和 chatLoop 的方法签名完全不用动。下面这段可以按项目习惯调整包名但三个变量的来源顺序建议保留环境变量优先默认值兜底方便本地直接跑。package com.nbsaas.boot; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; public class AiConfig { private static final String DEFAULT_BASE_URL https://taotoken.net/api; private static final String DEFAULT_MODEL 以模型广场当时列表为准的模型 ID; public static OpenAiStreamingChatModel getOpenAiStreamingChatModel() { String baseUrl readEnv(TAOTOKEN_BASE_URL, DEFAULT_BASE_URL); String apiKey readEnv(TAOTOKEN_API_KEY, YOUR_API_KEY); String modelName readEnv(TAOTOKEN_MODEL, DEFAULT_MODEL); return OpenAiStreamingChatModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelName) .temperature(0.2) .logRequests(true) .logResponses(true) .build(); } private static String readEnv(String key, String fallback) { String value System.getenv(key); return (value null || value.isBlank()) ? fallback : value.trim(); } }两个细节值得强调。第一baseUrl 只写到 https://taotoken.net/apilangchain4j 会自己在后面拼对话路径多写 /v1 反而容易拼出重复路径这是 404 的高频来源。第二logRequests 和 logResponses 在排查阶段建议打开它能让你看到实际发出去的模型名和 Base URL确认请求确实走了 TaoToken而不是被某个残留配置截走。2.3 模型 ID 不要猜去模型广场抄模型 ID 是最容易想当然的参数。社区文章里出现的名字、别人截图里的后缀都不等于你账号当前可用的列表。正确做法是把模型 ID 当成配置项而不是代码常量打开 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 的模型广场页面找到你要用的那一行把 ID 原样复制到环境变量里。如果同一份代码要在不同环境跑不同模型只改 TAOTOKEN_MODEL不要再碰 Java 文件。这样处理还有一个额外好处当模型列表发生调整时你只需要更新环境变量不用重新走一遍构建流程。AiConfig 保持稳定StreamApp2 保持稳定变化被压缩到一个字符串里。对后面要做多环境、多模型对照实验的场景这一点会省下大量时间。3. chatLoop 多轮调用链路里哪些环节真的走了 TaoToken3.1 第一轮请求messages 加 toolSpecifications 一起发出去StreamApp2 启动后先加载 tools.json再用 HttpToolExecutor.buildToolSpecifications() 把每个 HTTP Tool 转成 JSON Schema 形式的 ToolSpecification最后和 UserMessage 一起塞进 ChatRequest。这个 ChatRequest 通过 model.chat(...) 发出去是整条链路里唯一一次模型请求。它带上了工具描述所以模型知道“有哪些工具可以调”但它并不知道这些工具最终会去打哪个 HTTP 地址。换句话说第一轮请求里跟 TaoToken 有关的只有三样Base URL、API Key、模型 ID。toolSpecifications 只是请求体的一部分它描述的是工具能力不是工具实现。HttpToolExecutor 内部的 OkHttpClient、连接超时、读取超时跟模型通道完全隔离。理解这一点之后后面看日志就不会把“模型调用失败”和“HTTP Tool 调用失败”混成一类问题。3.2 ToolExecutionRequest 之后真正发 HTTP 的是 HttpToolExecutor模型返回的 AiMessage 里如果带 toolExecutionRequests()chatLoop 会遍历每一个 ToolExecutionRequest交给 httpExecutor.execute(req) 执行。这一步查的是 tools.json 里注册的 url 和 method走的是 GET 或 POST跟模型通道没有关系。比如问“北京今天天气怎么样适合出行吗”模型生成的 ToolExecutionRequest 里只有城市参数HttpToolExecutor 拿到的就是这个参数的 JSON 字符串解析成 Map 之后拼到业务 API 上。这里有个工程上的好处因为模型通道和工具执行是解耦的你可以单独替换任意一侧。今天把模型通道换成 TaoToken明天把某个天气 API 换成内部网关两边互不影响。chatLoop 只关心“有没有 tool call、执行结果是什么”它不关心 OpenAI 还是别的通道也不关心工具打的是公网还是内网。3.3 结果回写后第二轮请求才真正消耗更多 TokenHttpToolExecutor 返回的字符串会被包装成 ToolExecutionResultMessage追加到 messages 列表。如果本轮出现过工具调用chatLoop 就带着 round 1 递归进入下一轮把“用户问题 模型工具请求 工具执行结果”整体再发给模型。这一轮模型不再请求工具而是基于结果生成自然语言回答比如“今天北京多云温度适中适合出行”。从 Token 消耗角度看第一轮是“问题 工具 schema”第二轮是“问题 工具调用 工具结果”后者通常更长。这也是为什么把通道统一到 TaoToken 之后用量统计会比单轮问答高——不是哪里漏了而是 Agent Loop 本身就会产生 2 到 3 次模型请求。只要轮次没有失控这个消耗是符合预期的。4. 跑「北京今天天气怎么样适合出行吗」验证两轮调用4.1 期望看到的日志顺序启动 StreamApp2 之后标准输出里应该先出现 tools.json 的加载记录包含工具名和它对应的 URL然后进入“第 1 轮请求”流式打印模型的中间输出接着出现“[检测到工具调用开始执行...]”并打印 Tool 名称、参数、以及 HttpToolExecutor 返回的结果最后进入“第 2 轮请求”模型给出最终回答并打印“[最终回答完成]”。如果日志停在第一轮没有继续问题通常在工具结果回写或者递归条件上而不是模型通道。一个健康的标志是HttpToolExecutor 打出的 URL 是你业务系统的地址而模型请求打出的 Base URL 是 https://taotoken.net/api。两者同时出现说明模型通道和工具执行两条链路都在按预期工作。如果业务 URL 没出现说明模型没有触发 tool call如果模型请求地址不对才需要回到 AiConfig 检查环境变量。4.2 先用模型对话页面单独验一次 Key在把 StreamApp2 跑起来之前建议先用同一把 Key 做一次最小验证。打开 TaoToken 模型对话发一条普通消息确认模型能正常回复。这一步排除的是 Key 失效、模型 ID 写错、账户状态异常这类基础问题。如果这里就不通StreamApp2 里的报错再多也不用看。验证通过之后再回到代码里确认三件事baseUrl 是不是 https://taotoken.net/apiapiKey 是不是刚才那把 KeymodelName 是不是模型对话里选中的同一个。三样对齐之后StreamApp2 的第一轮请求基本不会在通道层面失败。剩下要调的就是工具描述和参数设计那属于 MCP Tools 本身的工程问题。4.3 用轮次和 Token 判断 Agent Loop 是否正常chatLoop 里有一个 maxRounds 参数示例里是 5。它的作用是防止模型反复请求工具导致死循环。正常情况下天气这种单工具任务两轮就结束第一轮请求工具第二轮总结答案。如果日志里出现了第 3 轮、第 4 轮通常是工具返回的结果让模型不满意比如返回了错误码、空响应或者参数校验没过模型就会尝试换一种方式再调一次。排这类问题时重点看两处HttpToolExecutor 返回的字符串是不是合法可读的结果以及 tools.json 里的 params 定义是不是跟业务 API 真实需要的参数一致。模型通道本身很少造成多轮循环它只负责根据描述决定要不要调工具。把工具描述写清楚比调 temperature 更有效。5. 换通道之后容易撞上的几个报错5.1 401Key 没读到、读错了、或者带了多余空格401 基本可以锁定在 apiKey 上。常见原因有三个环境变量没生效代码仍然读到 YOUR_API_KEY 占位复制 Key 时带上了首尾空格或换行多环境脚本里 export 覆盖了顺序最后一个生效的是旧值。排查时先把 logRequests 打开确认请求头里带的确实是预期的那把 Key再去 TaoToken 控制台 对照 Key 列表看它是否还存在、是否被禁用。有一点需要注意不要把 Key 写进 tools.json。tools.json 描述的是 HTTP Tool它不参与模型鉴权。如果你的业务 API 也需要鉴权那是 HttpToolExecutor 侧要处理的事情比如在 header 里加业务 token跟 TaoToken 的 Key 是两套东西别混在一起。5.2 404baseUrl 多写了 /v1或者错填成官网地址这个错误在换通道时出现频率很高。Base URL 要填 https://taotoken.net/api不要写成 https://taotoken.net/api/v1也不要写成落地页地址。langchain4j 的 OpenAI 兼容实现会按自己的规则拼接路径多一段或少一段都会导致 404。另一个变体是把官网地址误当成接口地址填进 baseUrl这种错误日志里通常会看到请求打到了非 API 路径上。判断方法很直接把 logRequests 打出的完整 URL 复制出来看一遍路径部分应该由 baseUrl 加对话路径组成中间不应该出现重复的 /v1。确认之后重新启动问题一般就消失了。不要把 UTM 参数加到 Base URL 上那些参数是给页面用的接口地址保持干净。5.3 工具被调用了但最终回答没出来这种情况通常跟模型通道无关。先确认 ToolExecutionResultMessage 是否真的追加进了 messages再确认 chatLoop 的递归条件有没有被正确置位。比较隐蔽的一种情况是工具返回的是错误字符串模型拿到之后仍然尝试回答但因为信息不足回答会变得含糊或者中途停止。这时候要回到 HttpToolExecutor确认业务 API 的返回结构是否被完整转成字符串。还有一种情况是 maxRounds 设得太小工具调用刚发生就被强制终止。排查阶段可以把它临时调大一点确认链路能走通之后再收紧。它跟模型通道是两回事别因为换了 Base URL 就忽略了这个参数。5.4 超时与连接错误要分开看StreamApp2 里有两个超时来源模型请求的超时和 HttpToolExecutor 里 OkHttpClient 的超时。如果日志显示模型请求超时检查网络到 https://taotoken.net/api 是否正常如果显示的是业务 API 超时那就跟模型通道无关去查那个天气接口本身。把这两类超时分开看能省掉很多来回改配置的时间。另外一个容易忽略的点是流式响应。OpenAiStreamingChatModel 走的是流式接口如果中间网络抖动onError 会被触发chatLoop 里的 latch 会提前释放表现为“回答没打完就结束”。这种情况下先看错误堆栈再决定是重试还是调整超时参数不要一上来就怀疑 Key。6. 把通道配置固定下来再考虑 Tool 网关6.1 别再让 AiConfig 成为每次切换的修改点这次改造真正有价值的地方不是把 baseUrl 换成了某一个地址而是把“通道信息”从代码里挪到了配置里。AiConfig 只保留读取逻辑环境变量负责提供值。以后要换模型改 TAOTOKEN_MODEL要换环境改 TAOTOKEN_API_KEY要换通道地址改 TAOTOKEN_BASE_URL。StreamApp2、chatLoop、HttpToolExecutor 全都不用重新编译。如果项目里还有其他地方直接 new 了模型实例建议一并收口到 AiConfig。否则会出现“主流程走 TaoToken某个旁路还在走旧通道”的割裂状态排查起来非常费劲。统一入口这件事越早做越省事。6.2 用控制台把这次调用对上账StreamApp2 跑通之后回到控制台对一下这次多轮调用的用量是确认配置生效最直接的方式。打开 TaoToken 控制台 查看调用记录能看到模型请求的时间、模型 ID 和消耗情况如果打算长期跑 Agent Loop可以在 Coding Plan 里看套餐是否够用需要给新环境再加一把 Key直接在 控制台 API Keys 创建即可。配好之后再跑一次“北京今天天气怎么样适合出行吗”对照日志里的两轮请求和控制台的记录你会看到一条清晰的链路模型请求走 TaoToken工具执行走 HttpToolExecutor两边各司其职。把这条链路固定成项目模板后面再加新的 HTTP Tool只需要往 tools.json 里追加一条定义模型通道那边不用再动。