大模型后端上线后,怎样把慢调用拆开看

下面以一次模拟排障为例:网关的尾延迟持续升高,需要区分检索、模型调用和工具执行分别占用了多少时间。

常规链路追踪往往只把一次请求记成一个很长的 HTTP Span。此时无法区分时间耗在检索、模型调用还是工具执行,也无法看出是否发生了不必要的重试。下面讨论的是用于设计观测点的排查模型,具体阈值应由服务的容量和用户等待预期决定。

这就是大模型服务集成的典型痛点。当系统从传统的确定性 RPC 调用演变为多轮交互的 Agent 链条,基于传统 HTTP/RPC 协议栈的链路追踪体系瞬间失灵。要彻底排查这类问题,必须在工程层将 LLM 语义拆解为粒度极细的 Trace 节点。


1. 传统 APM 探针失灵的根因拆解

在传统的微服务架构中,一个 RPC 请求的生命周期是确定且线性的。调用方发出 Request,提供方返回 Response,中间的数据库与缓存操作都有明确的客户端 Client 拦截器进行 Span 记录。

大模型 Agent 的运行逻辑截然不同。

一个用户的 Prompt 提交给 Agent 后,内部可能会经历以下过程:

  1. 向量数据库(Vector DB)进行 3 次相似度检索,拼接 Context;
  2. 首次调用 LLM API,模型返回 tool_calls 指令;
  3. 网关执行本地工具(如查询 MySQL 订单表);
  4. 将工具返回结果二次打包给 LLM API;
  5. LLM API 以 Server-Sent Events (SSE) 流式返回最终结果。

如果只记录网关入口和出口,这 5 个内部步骤的时间花在哪儿完全不可见。更危险的是,若 LLM 陷入 Tool Calling 死循环,网关内存和连接数会在极短时间内被撑爆。

我们需要重新定义大模型语义下的 OpenTelemetry 属性字段(Semantic Conventions),将 Prompt Token 数量、Completion Token 数量、模型名称以及 Tool 调用的上下文强制注入到 Trace Span 中。


2. Agent 链条全链路 Trace 模型设计

为了厘清大模型服务集成的物理调用关系,我们将 Trace 模型划分为三层结构:

  • Gateway Main Span:记录整个大模型请求的生命周期与总耗时。
  • Child Spans:分别记录 rag.retrieval(知识库检索)、llm.chat_completion(大模型交互)与 agent.tool_execution(本地工具执行)。
  • Stream Metrics:记录 SSE 首字延迟(TTFT, Time To First Token)与吐字速率(Tokens/sec)。

请求进入网关后,应在服务调用、异步任务和响应返回的每个节点持续透传 Trace 上下文。


3. 基于 OpenTelemetry Java SDK 的生产级埋点实现

在 Spring Boot 3.x 架构下,我们通过实现自定义的 OpenTelemetry TracerClientHttpRequestInterceptor,拦截所有的 HTTP 请求与 SSE 流,准确采集 LLM 元数据。

以下为生产环境落地的核心拦截器代码:

package com.example.llm.gateway.observability;

import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Component
public class LlmTracingInterceptor implements ClientHttpRequestInterceptor {

    private final Tracer tracer;

    public LlmTracingInterceptor(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
            throws IOException {
        
        // 只有针对 LLM API 的请求才注入专门的 LLM 语义 Span
        if (!request.getURI().getPath().contains("/chat/completions")) {
            return execution.execute(request, body);
        }

        Span span = tracer.spanBuilder("llm.chat_completion")
                .setAttribute("gen_ai.system", "DeepSeek")
                .setAttribute("gen_ai.request.model", "deepseek-chat")
                .setAttribute("gen_ai.request.temperature", 0.7)
                .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // 解析请求体中的 Prompt 字节数,避免全量解析 JSON 损耗性能
            span.setAttribute("gen_ai.request.prompt_bytes", body.length);
            
            long startTime = System.currentTimeMillis();
            ClientHttpResponse response = execution.execute(request, body);
            long latency = System.currentTimeMillis() - startTime;

            span.setAttribute("gen_ai.response.latency_ms", latency);
            span.setAttribute("http.status_code", response.getStatusCode().value());

            // 提取响应 Header 中的真实 Token 统计(如 Provider 支持)
            String promptTokens = response.getHeaders().getFirst("x-llm-prompt-tokens");
            String completionTokens = response.getHeaders().getFirst("x-llm-completion-tokens");
            if (promptTokens != null) {
                span.setAttribute("gen_ai.usage.prompt_tokens", Long.parseLong(promptTokens));
            }
            if (completionTokens != null) {
                span.setAttribute("gen_ai.usage.completion_tokens", Long.parseLong(completionTokens));
            }

            span.setStatus(StatusCode.OK);
            return response;
        } catch (Exception ex) {
            span.recordException(ex);
            span.setStatus(StatusCode.ERROR, "LLM API Call Failed: " + ex.getMessage());
            throw ex;
        } finally {
            span.end();
        }
    }
}

配合 Micrometer 自定义 Metrics,我们将 Token 消耗和首字延迟推送到 Prometheus:

package com.example.llm.gateway.observability;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class LlmMetricsCollector {

    private final Counter promptTokenCounter;
    private final Counter completionTokenCounter;
    private final Timer ttftTimer;

    public LlmMetricsCollector(MeterRegistry registry) {
        this.promptTokenCounter = Counter.builder("llm.tokens.prompt.total")
                .description("Total prompt tokens consumed")
                .register(registry);
        this.completionTokenCounter = Counter.builder("llm.tokens.completion.total")
                .description("Total completion tokens generated")
                .register(registry);
        this.ttftTimer = Timer.builder("llm.response.ttft.latency")
                .description("Time To First Token latency")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(registry);
    }

    public void recordTokens(long prompt, long completion) {
        promptTokenCounter.increment(prompt);
        completionTokenCounter.increment(completion);
    }

    public void recordTTFT(long durationMs) {
        ttftTimer.record(durationMs, TimeUnit.MILLISECONDS);
    }
}

4. 现场排障过程与生产终端调试命令

探针上线后,当报警再次响应时,工程师可以通过跳板机直接使用 Shell 命令和 OpenTelemetry 导出日志进行准确定位。

首先,检查 Prometheus 输出的 LLM 网关可观测指标口径:

# 查询当前网关节点实时暴露的 LLM 指标,过滤 Token 计数与延迟
curl -s "$SERVICE_BASE_URL/actuator/prometheus" | grep llm_

# 输出结果示例:
# llm_tokens_prompt_total 452810.0
# llm_tokens_completion_total 128490.0
# llm_response_ttft_latency_seconds_max 6.42

如果发现 ttft_latency 最大值达到 6.4 秒,说明大模型服务提供商本身响应缓慢。但如果 ttft 正常,而总耗时极长,则需要进一步抓取 Jaeger/Tempo 中的 TraceId。

在 Linux 终端通过 Grep 在网关日志中追查特定的异常 Trace:

# 过滤包含超时异常且带有 TraceId 的请求日志
grep "llm.chat_completion" /var/log/llm-gateway/app.log | grep "ERROR" -B 2 -A 5

# 使用 bpftrace 检查 JVM 网关进程与远端 LLM API (443 端口) 的 TCP 建立连接与 RTT 耗时
sudo bpftrace -e 'kprobe:tcp_v4_connect { $sk = (struct sock *)arg0; @start[tid] = nsecs; } kretprobe:tcp_v4_connect /@start[tid]/ { $lat = nsecs - @start[tid]; printf("Connect latency: %d ms\n", $lat / 1000000); delete(@start[tid]); }'

如果追踪显示模型首字延迟正常而工具 Span 反复出现,就应检查格式校验失败后的重试策略。不要先假定问题在模型侧;先用同一请求的 Span 顺序、错误类别和重试记录复现,再调整重试上限或回退路径。


5. 生产环境告警收口与观测防线

有了细粒度的可观测数据后,告警规则不能再粗暴地只对网关 HTTP P99 延迟进行报警。必须建立分层防线:

  1. 第一道防线:LLM Provider 状态告警
    llm.response.ttft.latency 的 P99 超过 4000ms 时,自动触发 API 提供商降级熔断,切换到备用模型节点。

  2. 第二道防线:Agent 死循环闸门
    在 Trace 拦截器中计数,单次 Request 内 agent.tool_execution 的 Span 数量超过 5 个时,强制终止 Trace 并抛出 ToolLoopException,不再浪费 Token 预算。

  3. 第三道防线:Token 异常飙高拦截
    监控单次请求 gen_ai.usage.prompt_tokens > 16384 的异常 Request,直接熔断并记录快照日志,防范恶意 Prompt 攻击拖垮网关内存。

通过这三层观测防线的建立,大模型服务不再是一个黑盒。排障时间从过去的上百分钟缩短到了 5 分钟以内。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐