第1760篇:分布式链路追踪在AI服务中的深度实践——从用户请求到LLM调用的全链路可观测性
第1760篇:分布式链路追踪在AI服务中的深度实践——从用户请求到LLM调用的全链路可观测性
适读人群:正在搭建或维护AI服务的后端工程师 | 阅读时长:约18分钟 | 核心价值:建立AI服务从入口到模型调用的完整链路追踪,让"慢在哪里"有答案
去年双十一前夕,我接到一个线上问题:用户反馈AI客服回复特别慢,有时候要等7秒以上,但日志里记录的LLM调用时间只有2-3秒,中间那4秒不知道去哪了。
我当时打开监控,看了半天,什么都看不出来。指标显示一切正常,日志里没有报错,Kafka消费延迟也正常,唯独用户体验差到离谱。
这种情况,我管它叫"黑盒慢"——你知道慢,但不知道慢在哪里。
解决这个问题用了我两天时间,最后发现是一个向量检索的连接池耗尽,请求在排队等连接,但这段等待时间没有被任何现有的监控捕捉到。
这件事之后,我重新审视了我们的AI服务观测体系,把分布式链路追踪从"锦上添花"的位置提升到了"基础设施"的位置。今天这篇,就把这套体系完整地讲一遍。
为什么AI服务比普通微服务更需要链路追踪
普通的API请求,大多数情况下出了问题你能很快定位:日志打得够的话,哪个方法慢、哪个SQL慢,一眼能看出来。
AI服务的调用链要复杂得多:
用户请求
→ 输入预处理(清洗、分词、截断)
→ 意图识别(可能是另一个小模型调用)
→ 知识库检索(向量化 + 向量检索 + 结果排序)
→ Prompt组装
→ LLM调用(可能有重试、可能有流式输出)
→ 结果后处理(格式化、安全过滤)
→ 缓存写入
→ 返回给用户每一个环节都可能成为瓶颈,而且不同请求的执行路径可能完全不同(有的命中缓存跳过了LLM,有的触发了降级逻辑走了另一个模型)。
没有链路追踪的情况下,你面对的是一堆孤立的指标和日志,它们各自在说自己的故事,但讲不清楚"这次请求"完整的时间线。
基础概念:Trace、Span和传播
在动手之前,先把概念理清楚。
Trace是一次完整请求的全程记录,从入口到出口。每个Trace有一个唯一的traceId。
Span是Trace里的一个工作单元,代表某个特定操作的执行区间(开始时间、结束时间、状态)。多个Span形成树状结构,父子关系表示调用关系。
Context传播是把traceId和spanId在服务之间、线程之间传递的机制,确保整条链路能串联起来。
这棵树里,你能看到每个环节的耗时,以及它们之间的关系。
技术选型:Spring Boot + Micrometer Tracing + Zipkin
Java生态里做分布式追踪,Spring Boot 3.x之后有了很好的原生支持。Micrometer Tracing提供了抽象层,底层可以接Brave(Zipkin的客户端)或者OpenTelemetry。
我们选的是 Micrometer Tracing + Brave + Zipkin,原因很简单:和Spring Boot集成最无缝,配置量最少,Zipkin的UI对于排查单次请求的链路也足够用。
如果你的团队已经用了Jaeger或者Tempo,换成OpenTelemetry导出器也很容易,抽象层都是一样的。
依赖配置:
<dependencies>
<!-- Micrometer Tracing核心 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<!-- Zipkin上报 -->
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
<!-- Spring Boot Actuator(提供/actuator/health等端点) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring AI Alibaba(用于连接LLM) -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M3.1</version>
</dependency>
</dependencies>基础配置:
management:
tracing:
sampling:
probability: 1.0 # 开发环境全量采样,生产环境建议0.1
zipkin:
tracing:
endpoint: http://zipkin:9411/api/v2/spans
spring:
application:
name: ai-chat-service # 这个名字会出现在Zipkin的服务列表里配置完这两项,Spring Boot会自动给所有HTTP请求、@Async方法、消息队列消费者注入追踪上下文。但AI调用是我们自己写的业务逻辑,需要手动埋点。
核心实践:给AI调用链手动埋点
Spring Boot的自动追踪能处理HTTP请求和数据库查询,但AI服务特有的几个环节——向量检索、Prompt组装、LLM调用——需要我们手动创建Span。
注入Tracer:
@Service
@Slf4j
public class AiChatService {
private final Tracer tracer;
private final ChatClient chatClient;
private final VectorStoreService vectorStoreService;
private final PromptBuilder promptBuilder;
public AiChatService(Tracer tracer,
ChatClient chatClient,
VectorStoreService vectorStoreService,
PromptBuilder promptBuilder) {
this.tracer = tracer;
this.chatClient = chatClient;
this.vectorStoreService = vectorStoreService;
this.promptBuilder = promptBuilder;
}
public ChatResponse processQuery(String userId, String query) {
// 外层的HTTP Span由Spring自动创建
// 我们在这里创建子Span
// 1. 输入处理
String processedQuery = processInput(query);
// 2. 向量检索(手动创建Span)
List<Document> context = retrieveContext(processedQuery);
// 3. Prompt组装
String finalPrompt = promptBuilder.build(processedQuery, context);
// 4. LLM调用(手动创建Span)
return callLlm(finalPrompt, userId);
}
private List<Document> retrieveContext(String query) {
// 创建一个专门的Span来追踪向量检索
Span span = tracer.nextSpan()
.name("vector-store.search")
.tag("query.length", String.valueOf(query.length()))
.start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
long start = System.currentTimeMillis();
List<Document> docs = vectorStoreService.similaritySearch(query, 5);
long elapsed = System.currentTimeMillis() - start;
span.tag("result.count", String.valueOf(docs.size()));
span.tag("latency.ms", String.valueOf(elapsed));
return docs;
} catch (Exception e) {
span.error(e);
throw e;
} finally {
span.end();
}
}
private ChatResponse callLlm(String prompt, String userId) {
Span span = tracer.nextSpan()
.name("llm.chat")
.tag("model", "qwen-max")
.tag("user.id", userId)
.tag("prompt.tokens", String.valueOf(estimateTokens(prompt)))
.start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
ChatResponse response = chatClient.prompt()
.user(prompt)
.call()
.chatResponse();
// 把模型返回的token用量也记录进Span
if (response.getMetadata() != null) {
Usage usage = response.getMetadata().getUsage();
span.tag("completion.tokens", String.valueOf(usage.getGenerationTokens()));
span.tag("total.tokens", String.valueOf(usage.getTotalTokens()));
}
return response;
} catch (Exception e) {
span.error(e);
log.error("LLM调用失败, traceId={}", tracer.currentSpan().context().traceId(), e);
throw e;
} finally {
span.end();
}
}
private String processInput(String query) {
// 简单的输入清洗,通常不需要单独创建Span
// 如果这里有复杂逻辑(比如调用另一个服务做分词),再加
return query.trim().replaceAll("\\s+", " ");
}
private int estimateTokens(String text) {
// 粗略估算:中文约1.5字符/token,英文约4字符/token
return (int) (text.length() / 2);
}
}这段代码里有几个关键点:
第一,Span命名要有意义。 vector-store.search 和 llm.chat 这样的命名,在Zipkin里一眼就能看出在干什么。不要用含糊的名字比如 service-call 或者 step-1。
第二,Tag要携带诊断信息。 我在LLM Span上打了prompt.tokens和completion.tokens,这样排查高延迟请求时,能立刻知道是不是因为prompt太长或者输出太多导致的慢。
第三,异常一定要记录到Span。 span.error(e) 会把这个Span标记为失败状态,在Zipkin里会用红色高亮显示,非常容易发现。
进阶:异步调用和线程池中的上下文传播
AI服务里经常会有异步操作,比如向量化是个耗时操作,我们可能用线程池并发处理多个片段。这里有个容易踩的坑:
默认的线程池不会传播Trace Context。
也就是说,你在主线程创建了Span,把任务提交给线程池,在那个工作线程里再创建的Span,会变成一个孤立的新Trace,而不是原来Trace的子Span。Zipkin里会出现两个不相关的Trace,让你摸不着头脑。
解决方案是用 ContextSnapshot 包裹任务:
@Configuration
public class TracingConfig {
@Bean
public ExecutorService aiTaskExecutor(ObservationRegistry observationRegistry) {
// 使用Micrometer提供的ContextExecutorService包装
ExecutorService base = Executors.newFixedThreadPool(10);
return ContextExecutorService.wrap(base,
() -> ContextSnapshot.captureAll(observationRegistry));
}
}使用时:
@Service
public class EmbeddingService {
@Autowired
private ExecutorService aiTaskExecutor;
public List<float[]> batchEmbed(List<String> texts) {
// 提交到aiTaskExecutor的任务会自动继承当前Trace Context
List<CompletableFuture<float[]>> futures = texts.stream()
.map(text -> CompletableFuture.supplyAsync(
() -> embedSingle(text),
aiTaskExecutor // 用包装过的线程池
))
.collect(Collectors.toList());
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
private float[] embedSingle(String text) {
// 这里创建的Span会自动成为调用者Span的子Span
Span span = tracer.nextSpan().name("embedding.single").start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
return embeddingModel.embed(text);
} finally {
span.end();
}
}
}用这个方案之后,你在Zipkin里能看到一个父Span下面并行跑着多个embedding子Span,非常直观。
跨服务传播:让traceId穿越Kafka消息
如果你的AI服务是异步架构,请求进来之后发消息到Kafka,另一个服务消费并真正调用LLM,那traceId还要能穿越Kafka消息。
发送端(Producer):
@Service
public class AiRequestProducer {
private final KafkaTemplate<String, AiRequest> kafkaTemplate;
private final Tracer tracer;
public void send(AiRequest request) {
Span span = tracer.nextSpan().name("kafka.produce").start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
// 把当前Trace Context注入到Kafka消息Header
ProducerRecord<String, AiRequest> record =
new ProducerRecord<>("ai-requests", request.getId(), request);
// Spring Kafka会自动把当前Span的Context注入到消息Header
// 前提是使用的是Spring Kafka,并且添加了micrometer-tracing的依赖
kafkaTemplate.send(record);
} finally {
span.end();
}
}
}接收端(Consumer):
@Component
public class AiRequestConsumer {
@KafkaListener(topics = "ai-requests")
public void consume(ConsumerRecord<String, AiRequest> record) {
// Spring Kafka + Micrometer Tracing会自动从消息Header恢复Context
// 所以这里创建的Span会自动成为Producer那边Span的子Span
AiRequest request = record.value();
aiChatService.processQuery(request.getUserId(), request.getQuery());
}
}这样配置之后,你在Zipkin里查一个traceId,能看到完整的链路:HTTP请求→消息发送→消息消费→向量检索→LLM调用,全链路端到端可见。
实战:用链路追踪定位慢请求的根因
回到最开头那个问题——4秒不知道消失在哪里。有了链路追踪之后,查这个问题就简单多了。
在Zipkin里按照高延迟过滤(比如>5秒的请求),找到一个具体的trace,展开来看:
Total: 7.2s
├── HTTP /api/chat (7.2s)
│ ├── input-process (0.01s)
│ ├── vector-store.search (4.1s) ← 这里异常慢
│ │ └── [等待连接池] (3.8s) ← 大部分时间在这
│ │ └── [实际检索] (0.3s)
│ ├── llm.chat (2.8s)
│ └── result-process (0.02s)一眼就看出来了:vector-store.search 花了4.1秒,但实际检索只用了0.3秒,剩下3.8秒是在等连接池。
顺着这个线索去查连接池配置:
# 原来的配置(问题所在)
vector-store:
qdrant:
max-connections: 5 # 太少了
connection-timeout: 5000# 修改后的配置
vector-store:
qdrant:
max-connections: 50
min-idle: 10
connection-timeout: 3000
max-idle-time: 30000调整之后,vector-store.search 的耗时稳定在了0.3-0.5秒,慢请求问题彻底消失。
没有链路追踪,我大概要用"看看连接池配置吧"这种猜测方式花上更长时间排查。
采样策略:不是所有请求都要追踪
全量采样(probability: 1.0)在开发和测试环境没问题,但在高流量的生产环境,追踪本身会产生额外开销,也会产生大量的存储压力。
生产环境推荐两种策略结合:
1. 基础概率采样 + 错误请求全量采样:
@Bean
public Sampler sampler() {
// 正常请求采样10%,但出错的请求全量采样
return new Sampler() {
private final Sampler rateBased = Sampler.create(0.1f);
@Override
public boolean isSampled(long traceId) {
return rateBased.isSampled(traceId);
}
};
}
// 在异常处理器里强制开启采样
@ControllerAdvice
public class GlobalExceptionHandler {
@Autowired
private Tracer tracer;
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
// 标记当前Span为错误,确保被采样
Span currentSpan = tracer.currentSpan();
if (currentSpan != null) {
currentSpan.error(e);
// 强制不丢弃这个trace
currentSpan.tag("sampling.force", "true");
}
// ...
}
}2. 高延迟请求自动采样:
@Component
public class LatencyBasedSamplingFilter implements Filter {
private final Tracer tracer;
private static final long SLOW_THRESHOLD_MS = 3000;
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long elapsed = System.currentTimeMillis() - start;
// 超过阈值的请求,追加标记(方便后续在Zipkin里过滤)
if (elapsed > SLOW_THRESHOLD_MS) {
Span current = tracer.currentSpan();
if (current != null) {
current.tag("slow.request", "true");
current.tag("total.latency.ms", String.valueOf(elapsed));
}
}
}
}这样你可以在Zipkin里按 slow.request=true 过滤,专门看慢请求的链路分布。
把traceId透传给用户:提升问题定位效率
最后说一个不太被重视但很实用的细节:把traceId返回给客户端。
用户遇到问题来反馈的时候,他能告诉你的通常只有"我大概什么时间遇到了问题",你还需要在海量日志里捞对应的记录。
如果把traceId放在HTTP响应头里,用户截图一发,你直接在Zipkin里搜这个ID,两秒找到对应链路:
@Component
public class TraceIdResponseFilter implements Filter {
private final Tracer tracer;
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response);
// 在响应头里追加traceId
HttpServletResponse httpResponse = (HttpServletResponse) response;
Span current = tracer.currentSpan();
if (current != null && !httpResponse.isCommitted()) {
httpResponse.setHeader("X-Trace-Id",
current.context().traceId());
}
}
}前端可以在报错弹窗里把这个ID显示出来,或者记到错误上报里。用户遇到问题,直接把错误ID发给你,定位时间从"找到了没" 变成 "直接搜索"。
链路追踪的几个陷阱
最后说几个我在实际使用中踩过的坑:
陷阱一:Span未关闭导致内存泄漏。 每个 span.start() 必须有对应的 span.end(),而且要放在 finally 块里。忘记关闭的Span会一直持有引用,导致内存泄漏。用 try-with-resources 的方式是最安全的。
陷阱二:在响应式代码里上下文传播失效。 如果你用了WebFlux,追踪上下文的传播方式和阻塞模型完全不同,需要用 ReactorNetty、reactor-core 的上下文机制,不能直接用 ThreadLocal。
陷阱三:采样率设置不合理导致Zipkin存储压力。 生产环境全量采样,如果你的QPS是1000,每天就是8600万个请求的追踪数据。大部分Zipkin部署扛不住这个量级。10%的采样率对于排查大多数问题已经足够。
陷阱四:Span里的Tag包含敏感信息。 Span里不要把用户的原始输入作为Tag记录,特别是可能包含个人信息的场景。用摘要、哈希或者脱敏处理之后的值代替。
建立好链路追踪之后,你会发现每次排查问题的感觉完全不一样了:不再是"凭感觉猜哪里出问题",而是"看Zipkin,找最宽的红色条,就是它"。
这种从"瞎猜"到"有证据"的转变,是我认为AI服务可观测性建设里ROI最高的一个投资。
