
1. 线上大模型调用慢为什么日志里什么都看不出来智能客服接口 P99 从 800ms 涨到 12s 的那个凌晨值班同学翻遍了应用日志CPU 正常、GC 正常、数据库连接池正常日志里只有一行「chat request finished, cost11842ms」。这行日志告诉你「慢」但完全不告诉你「慢在哪」。是向量检索退化了是 Prompt 拼装卡住了还是远端大模型 API 那一段在排队每个服务都只看到自己那一段整条链路的全局视图是缺失的。这就是 Java 服务接入大模型 API 之后最典型的排障困境链路断裂、耗时无法归因。传统微服务里一次 RPC 调用几百毫秒你还能靠日志时间戳拼出个大概但大模型调用动辄 1~30 秒流式响应甚至以分钟计靠日志时间戳去对齐误差比你要找的瓶颈还大。链路追踪Distributed Tracing要解决的就是这件事把一次请求穿越的所有节点还原成一棵 Span 树让每一毫秒的耗时都有归属。而 SkyWalking 作为观测底座最大的价值是无侵入 Agent 埋点——你不用改业务代码就能拿到服务拓扑和基础 Trace。但问题在于大模型调用这一段是 HTTP 出口SkyWalking 默认的 HTTP 插件能抓到 URL 和状态码却抓不到「这次调用花了多少 Token、首字时延多少、是不是流式」。所以你需要做两件事一是让 TraceId 在 HTTP 出口和异步回调里正确透传二是给大模型调用段补上自定义 Span 和 Tag。这篇聚焦的就是这个落地过程从 TraceId 透传路径到 SkyWalking 探针与自定义 Span 的可复制配置再到一次线上慢调用的验证动作。适合已经有 Spring Boot 服务、正在或准备接入大模型 API、想把 LLM 调用纳入既有可观测性体系的 Java 程序员。读完你能拿到一套可以直接抄的配置以及一套排查「大模型调用慢在哪」的固定动作。2. TaoToken 前置把大模型出口统一成一个可观测的 Base URL在讲 SkyWalking 埋点之前得先把大模型调用的出口统一掉。原因很直接如果你的 Java 服务里散落着好几处直接拼 HTTP 请求调不同厂商大模型 API 的代码那 SkyWalking 抓到的就是一堆 URL 各异的 Exit Span你没法用统一的 Tag 规范去标记它们也没法在拓扑图里把它们归成一类。我试过的做法是把所有大模型调用收敛到一个统一的网关出口Java 侧只认一个 Base URL。TaoToken 在这里扮演的就是这个统一出口的角色——它提供 OpenAI 兼容的 API 形态Java 侧用标准的 HTTP 客户端或 OpenAI SDK 就能调SkyWalking 抓到的 Exit Span 的 peer 和 URL 就是固定的方便你统一打 Tag。具体来说你需要先拿到 API Key然后确认三件事Base URLhttps://taotoken.net/api这是所有大模型调用的统一入口Java 侧配置里写死这个。API Key在控制台的 API Keys 页面创建格式通常是sk-开头的一串字符。Model ID你要调用的具体模型标识比如gpt-4o、claude-3-5-sonnet这类调用时放在请求体的model字段里。这三件套Base URL Key Model ID是后面所有配置的基础。如果你用的是 Claude Code 这类工具或者 Cline 的 MCP 配置也是同样的三件套逻辑只是配置文件的位置和字段名不同。Java 服务里我建议把这三个值放到配置中心或环境变量不要硬编码在代码里方便后面在 SkyWalking 的 Tag 里动态读取。拿到 Key 之后先别急着写埋点代码用 curl 验证一下出口是通的curl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的Key \ -d { model: gpt-4o, messages: [{role: user, content: ping}], max_tokens: 10 }返回里有choices数组和usage字段说明出口正常。这个usage字段里的prompt_tokens和completion_tokens就是后面要挂到 SkyWalking Span Tag 上的成本数据来源。注意Base URL 写https://taotoken.net/api不要多加/v1之外的路径。OpenAI 兼容接口的完整路径是/api/v1/chat/completionsSDK 会自动拼/v1部分你只需要配到/api。这一步做完你的 Java 服务就有了一个统一、可观测的大模型出口。接下来才是 SkyWalking 的接入和埋点。3. 可复制配置SkyWalking 探针 自定义 Span 埋点这一节是全文的技术核心给你可以直接抄的配置。分三块SkyWalking Java Agent 的启动参数、Spring Boot 里的大模型调用封装、以及自定义 Span 的埋点代码。3.1 SkyWalking Agent 启动参数假设你已经下载了 SkyWalking Java Agentskywalking-agent目录在启动 Spring Boot 服务时加上 JVM 参数java -javaagent:/opt/skywalking-agent/skywalking-agent.jar \ -Dskywalking.agent.service_namechat-service \ -Dskywalking.collector.backend_service127.0.0.1:11800 \ -Dskywalking.plugin.toolkit.log.grpc.reporter.server_host127.0.0.1 \ -Dskywalking.plugin.toolkit.log.grpc.reporter.server_port11800 \ -jar chat-service.jar关键参数说明参数作用建议值service_name在 SkyWalking UI 里显示的服务名用你的实际服务名别用默认collector.backend_serviceOAP 后端地址OAP 的 gRPC 端口默认 11800agent.sample_n_per_3_secs采样率生产环境建议 -1全采样或按量调整如果你用 Docker 部署把-javaagent参数加到JAVA_TOOL_OPTIONS环境变量里ENV JAVA_TOOL_OPTIONS-javaagent:/opt/skywalking-agent/skywalking-agent.jar \ -Dskywalking.agent.service_namechat-service \ -Dskywalking.collector.backend_serviceoap:118003.2 大模型调用的统一封装Java 侧调大模型我建议封装成一个LlmClient内部用OkHttp或WebClient发请求。这样 SkyWalking 的 HTTP 插件会自动为这个出口创建一个 Exit Span你只需要在这个 Span 上补 Tag。Component public class LlmClient { private final OkHttpClient httpClient; private final String baseUrl; private final String apiKey; public LlmClient(Value(${llm.base-url}) String baseUrl, Value(${llm.api-key}) String apiKey) { this.baseUrl baseUrl; this.apiKey apiKey; this.httpClient new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) // 大模型调用超时设长 .build(); } public LlmResponse chat(String model, ListMessage messages) throws IOException { // 这里用 SkyWalking 的 ActiveSpan 拿到当前 Span ActiveSpan span ActiveSpan.tag(llm.model, model); long start System.currentTimeMillis(); try { String body buildRequestBody(model, messages); Request request new Request.Builder() .url(baseUrl /v1/chat/completions) .header(Authorization, Bearer apiKey) .header(Content-Type, application/json) .post(RequestBody.create(body, MediaType.parse(application/json))) .build(); try (Response response httpClient.newCall(request).execute()) { long cost System.currentTimeMillis() - start; span.tag(llm.cost.ms, String.valueOf(cost)); if (!response.isSuccessful()) { span.tag(llm.error, http_ response.code()); throw new IOException(LLM call failed: response.code()); } LlmResponse llmResponse parseResponse(response.body().string()); // 把 Token 消耗挂到 Span 上 span.tag(llm.token.prompt, String.valueOf(llmResponse.getPromptTokens())); span.tag(llm.token.completion, String.valueOf(llmResponse.getCompletionTokens())); span.tag(llm.cost.usd, calculateCost(llmResponse)); return llmResponse; } } finally { span.stop(); } } }这里的关键是ActiveSpan.tag()——它拿到的是当前线程的活跃 Span也就是 SkyWalking HTTP 插件为这次出口调用创建的那个 Exit Span。你不需要自己创建 Span只需要往上挂 Tag。3.3 异步回调里的 TraceId 透传大模型流式响应或者异步回调场景问题就来了HTTP 请求是在线程 A 发起的但响应回调可能在线程 B 执行。SkyWalking 的上下文默认绑定在线程上跨线程就丢了。解决办法是用 SkyWalking 的ContextManager手动传递上下文public CompletableFutureLlmResponse chatAsync(String model, ListMessage messages) { // 捕获当前上下文 ContextSnapshot snapshot ContextManager.capture(); return CompletableFuture.supplyAsync(() - { // 在异步线程里恢复上下文 ContextManager continued snapshot.continueContext(); try { return chat(model, messages); } finally { continued.stop(); } }); }如果是流式响应SSE每一段 chunk 的回调都要确保在同一个 Trace 下。我建议在流式场景里把整个流式会话建模成一个 Span首字时延TTFT和完整生成时延TPOT分别打点span.tag(llm.stream.ttft.ms, String.valueOf(ttft)); span.tag(llm.stream.total.ms, String.valueOf(totalCost));这样在 SkyWalking UI 里你就能看到一次流式调用被拆成了「等待首 token」和「流式输出」两个区间而不是一个笼统的 12 秒。3.4 配置文件汇总把上面的配置整理成application.ymlllm: base-url: https://taotoken.net/api api-key: ${LLM_API_KEY} default-model: gpt-4o timeout: connect: 10s read: 120s skywalking: agent: service_name: chat-service collector: backend_service: 127.0.0.1:11800环境变量LLM_API_KEY在部署时注入不要写进配置文件提交到 Git。4. 验证请求一次线上慢调用的排查动作配置写完怎么验证它真的生效了我给你一套固定的排查动作照着做就能定位「大模型调用慢在哪」。4.1 构造一次慢调用先在测试环境构造一次慢调用。最简单的办法是调一个长 Prompt或者故意让向量检索退化。比如curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {query: 请详细解释一下分布式链路追踪的原理不少于2000字}这个请求会触发一次大模型调用耗时应该在几秒到十几秒。4.2 在 SkyWalking UI 里下钻打开 SkyWalking UI进入「追踪」页面按服务名chat-service过滤找到刚才那次请求的 Trace。你应该能看到一棵 Span 树大致结构是chat-service (Entry Span, 11842ms) ├─ vector-search (Exit Span, 9021ms) ← 慢在这里 ├─ prompt-assemble (Local Span, 12ms) └─ POST /api/v1/chat/completions (Exit Span, 2801ms) ├─ llm.model gpt-4o ├─ llm.token.prompt 1280 ├─ llm.token.completion 540 └─ llm.cost.usd 0.021如果vector-search那个 Span 占了 9 秒根因就找到了——不是大模型慢是 RAG 检索退化。如果POST /api/v1/chat/completions那个 Span 占了大部分时间那就要看llm.stream.ttft.ms这个 Tag判断是首字时延高还是流式输出慢。4.3 用 TraceId 关联日志SkyWalking 的 TraceId 会通过日志插件注入到你的日志里。在logback-spring.xml里加上appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{HH:mm:ss.SSS} [%thread] %-5level [%X{tid}] %logger{36} - %msg%n/pattern /encoder /appender%X{tid}就是 SkyWalking 注入的 TraceId。这样你在日志里搜某个 TraceId就能看到这次请求在所有服务里的日志配合 SkyWalking 的 Span 树根因定位就是分钟级的事。4.4 验证 Token 成本归因在 SkyWalking UI 的「追踪」详情页点开那个 Exit Span你应该能看到llm.token.prompt、llm.token.completion、llm.cost.usd这几个 Tag。如果没看到说明你的ActiveSpan.tag()没生效检查一下是不是在异步线程里丢了上下文。这一步验证通过说明你的大模型调用已经完整纳入了 SkyWalking 的可观测性体系。后面再出慢调用你就有了一套固定的下钻路径先看 Span 树找最慢的那一段再看 Tag 判断是网络、Token 还是流式的问题。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth配置过程中最容易踩的坑我按真实报错整理成对照表。5.1 401 UnauthorizedHTTP 401 Unauthorized {error: {message: Invalid API key, type: invalid_request_error}}这是最常见的。原因通常是 API Key 没配对或者环境变量没注入。检查三件事LLM_API_KEY环境变量是否真的传进了容器Key 前面有没有多空格Base URL 是不是写成了https://taotoken.net/api而不是别的路径。如果用的是 Claude Code 或 Cline检查配置文件里的apiKey字段是不是写在了正确的位置。5.2 local proxy failedlocal proxy failed: dial tcp 127.0.0.1:11800: connect: connection refused这个报错是 SkyWalking Agent 连不上 OAP 后端。检查collector.backend_service的地址和端口OAP 默认 gRPC 端口是 11800。如果你在 Docker 里跑127.0.0.1指的是容器自己要改成 OAP 的服务名或宿主机 IP。5.3 reading choices 报错java.lang.NullPointerException: Cannot read the array length because choices is null这是解析响应时choices字段为空。原因通常是请求体格式不对或者模型返回了错误但 HTTP 状态码是 200。检查你的buildRequestBody方法确保messages数组非空、model字段正确。另外如果用了流式响应choices[0].delta和choices[0].message的结构不一样别用同一套解析逻辑。5.4 OAuth 相关报错OAuth token exchange failed: invalid_grant如果你用的是需要 OAuth 的模型服务检查 token 是否过期。TaoToken 的 API Key 是长期有效的不需要 OAuth 刷新流程所以如果你看到 OAuth 报错大概率是代码里混用了别的认证方式。统一用Authorization: Bearer sk-xxx这个 header。5.5 三件套检查清单出现任何调用失败先按这个清单过一遍检查项正确值常见错误Base URLhttps://taotoken.net/api多写/v1或漏写/apiAPI Keysk-开头环境变量注入硬编码、多空格、过期Model ID如gpt-4o拼写错误、大小写不对请求路径/v1/chat/completionsSDK 自动拼手写容易错超时设置read timeout ≥ 120s默认 10s大模型调用必超时注意SkyWalking 的 HTTP 插件默认会抓取所有出口 HTTP 调用如果你的服务里还有其他外部调用Span 树会很长。建议在agent.config里配置plugin.http.ignore_path排除掉不关心的路径。6. 把大模型调用纳入可观测性体系下一步做什么到这里你已经完成了从 TraceId 透传到自定义 Span 埋点的完整落地。回顾一下核心动作用 TaoToken 统一大模型出口拿到 Base URL Key Model ID 三件套用 SkyWalking Java Agent 无侵入接入在LlmClient里用ActiveSpan.tag()挂上 Token 和成本 Tag用ContextManager解决异步回调的上下文透传最后用一次慢调用验证整条链路。这套配置的价值在于它把大模型调用从「黑盒」变成了「透明管道」。以前你只知道接口慢了 12 秒现在你能精确看到是向量检索占了 9 秒还是大模型首字时延占了 2 秒。成本维度也一样每次调用的 Token 消耗和费用都挂在 Span 上月底对账不用再猜。如果你还没拿到 API Key先去控制台创建一个然后按第 2 节的 curl 命令验证出口。接入文档里有更详细的参数说明和错误码对照。验证模型连通性可以用模型对话页面直接测不用写代码。如果你打算长期做编码类或 Agent 类的大模型调用Coding Plan 的额度模式会比按量计费更划算适合高频调用的场景。下一步的路线我建议你先在测试环境把第 3 节的配置跑通然后拿一次真实的慢调用做下钻练习。等你熟悉了 Span 树的结构再考虑把 Metrics 和 Logging 也通过 TraceId 打通——那时候你的可观测性体系才算真正完整。