AI Agent 的可观测性:从链路追踪到成本监控的 Java 实战

系列文章

本篇是 AI Agent 深度解析系列的第 17 篇。以下是已发布的全部文章:

  1. 从零理解 RAG:检索增强生成完整指南
  2. 理解 AI Agent 的大脑:ReAct 模式从入门到实战
  3. AI Agent 的记忆系统:从 ChatMemory 到持久化记忆的 Java 实战
  4. MCP 模型上下文协议:AI 的万能接口与 MCP Server 实战
  5. AI Agent 的工具箱:深入理解 Tool Use 与 Spring AI Function Calling 实战
  6. AI Agent 的记忆力是怎么实现的——LangChain4j Memory 机制深度解析
  7. 让 AI 学会说人话——Spring AI 结构化输出实战
  8. AI Agent 的规划大脑:从任务分解到自适应执行策略
  9. AI Agent 的灵魂对话:Prompt Engineering 系统提示词设计的艺术与工程
  10. AI Agent 团队协作:多 Agent 系统架构设计与 Java 实战
  11. AI Agent 评估与优化:从基准测试到生产环境的质量守护实战
  12. 当 RAG 遇上知识图谱:GraphRAG 原理与 Java 实战
  13. Embedding 向量化的魔法:从文本到向量的数学之旅与 Java 实战
  14. 当 RAG 遇到 Agent:Agentic RAG 的架构设计与 Java 实战
  15. AI Agent 的知识检索引擎:从向量搜索到智能检索策略的 Java 实战
  16. AI Agent 的安全防线:Prompt 注入防御与生产级安全防护实战
  17. AI Agent 的可观测性:从链路追踪到成本监控的 Java 实战(本文)

你的 Agent 上线了,然后呢?

想象一个场景:你精心打造的 AI Agent 客服系统上线了,用户开始用它处理退换货、查物流、咨询产品。第一天运行”看起来”很正常,直到老板拿着账单找到你——“这个月 API 调用费怎么 12 万?”

你开始排查:是哪个用户在疯狂调用?是哪个 Tool 调用链路特别长导致 Token 消耗暴增?是 Agent 陷入了推理循环反复调用同一个工具?还是模型返回了大量冗余内容?

你发现,Agent 系统和传统 Web 应用有一个根本区别:传统应用的每次请求消耗的资源基本固定,而 Agent 的一次用户交互可能触发 3-10 次 LLM 调用,每次调用消耗的 Token 数量差异巨大。没有可观测性,你就是在盲飞。

AI Agent 评估与优化 一文中,我们聊了怎么评估 Agent 的质量。但评估是离线的、面向结果的;可观测性是在线的、面向过程的——它让你在 Agent 运行时实时看到”它在做什么、花了多少钱、哪里出了问题”。

这就是今天要聊的内容。


可观测性的三大支柱,在 Agent 世界里的新含义

传统后端的可观测性有三大支柱:Traces(链路追踪)、Metrics(指标)、Logs(日志)。到了 Agent 世界,这三者的含义都发生了变化。

Traces:不只是 HTTP 调用链

在传统微服务中,一个 Trace 就是一条 HTTP 调用链:Gateway → Service A → Service B → Database

但在 Agent 系统中,一次用户交互的 Trace 可能长这样:

1
2
3
4
5
6
7
8
9
10
11
用户输入: "我的订单 #12345 什么时候到?"
├── LLM Call #1 (ReAct 推理) — 1200 tokens, 800ms
│ └── 决策: 调用 getOrderStatus tool
├── Tool Call: getOrderStatus("12345") — 50ms
│ └── 返回: {"status": "已发货", "tracking": "SF1234567"}
├── LLM Call #2 (生成回复) — 800 tokens, 600ms
│ └── 决策: 调用 getTrackingInfo tool
├── Tool Call: getTrackingInfo("SF1234567") — 80ms
│ └── 返回: {"location": "深圳转运中心", "eta": "明天"}
└── LLM Call #3 (最终回复) — 600 tokens, 500ms
└── 输出: "您的订单已发货,目前在深圳转运中心..."

关键区别:Agent 的 Trace 是多轮 LLM 调用 + 工具调用的复合链路,每一步都有独立的 Token 消耗和延迟。你需要看到的不只是”这个请求花了多久”,而是”Agent 推理了几轮、每轮消耗了多少 Token、调用了哪些工具”。

Metrics:Token 是新的 CPU

传统应用监控 CPU、内存、QPS。Agent 应用的核心指标变成了:

指标 传统应用等价物 含义
Token 消耗/请求 CPU 时间/请求 单次交互的资源消耗
LLM 调用次数/请求 RPC 调用次数/请求 Agent 的推理深度
LLM 延迟 (P50/P99) RPC 延迟 用户等待时间
Tool 调用成功率 下游服务成功率 工具是否正常工作
每日 Token 成本 云计算费用 最直接的财务指标
缓存命中率 缓存命中率 相似问题是否复用

其中,Token 消耗是 Agent 系统独有的、最重要的指标。它直接决定你的运营成本。

Logs:结构化是命

Agent 的日志必须是结构化的。一条普通的 logger.info("Agent responded") 毫无价值。你需要的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"timestamp": "2026-06-19T10:30:00Z",
"traceId": "abc-123",
"userId": "user-456",
"agentType": "customer-service",
"llmProvider": "openai",
"model": "gpt-4o",
"promptTokens": 1200,
"completionTokens": 800,
"totalTokens": 2000,
"latencyMs": 800,
"toolCalls": ["getOrderStatus"],
"reasoning": "User asked about order status, need to fetch from backend",
"cost": 0.006
}

有了这样的结构化日志,你才能回答”哪个用户的请求最费钱”、”哪个 Tool 调用导致了延迟飙升”这类问题。


LangChain4j 的可观测性方案

LangChain4j 提供了两层可观测性能力:ChatModelListener(应用层)OpenTelemetry 集成(基础设施层)

第一层:ChatModelListener——最灵活的观测点

ChatModelListener 是 LangChain4j 提供的回调接口,让你在 LLM 调用的各个生命周期节点插入自定义逻辑。它的设计思路类似于 Servlet 的 Filter 链——你可以在请求发出前、响应返回后、出错时分别执行逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import dev.langchain4j.model.chat.listener.ChatModelErrorContext;
import dev.langchain4j.model.chat.listener.ChatModelListener;
import dev.langchain4j.model.chat.listener.ChatModelRequestContext;
import dev.langchain4j.model.chat.listener.ChatModelResponseContext;

@Component
public class AgentObservabilityListener implements ChatModelListener {

private static final Logger log = LoggerFactory.getLogger(AgentObservabilityListener.class);
private final MeterRegistry meterRegistry;

public AgentObservabilityListener(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}

@Override
public void onRequest(ChatModelRequestContext context) {
// 请求发出前:记录开始时间,注入 Trace ID
context.attributes().put("startTime", System.nanoTime());
String traceId = MDC.get("traceId");
if (traceId != null) {
context.attributes().put("traceId", traceId);
}
log.debug("LLM request initiated, model: {}", context.chatRequest().modelName());
}

@Override
public void onResponse(ChatModelResponseContext context) {
// 响应返回后:记录 Token 消耗、延迟、成本
long startTime = (long) context.attributes().getOrDefault("startTime", 0L);
long latencyMs = (System.nanoTime() - startTime) / 1_000_000;

var tokenUsage = context.chatResponse().tokenUsage();
String model = context.chatResponse().modelName();

// 记录指标
meterRegistry.counter("llm.requests.total",
"model", model,
"status", "success"
).increment();

meterRegistry.counter("llm.tokens.total",
"model", model,
"type", "prompt"
).increment(tokenUsage.inputTokenCount());

meterRegistry.counter("llm.tokens.total",
"model", model,
"type", "completion"
).increment(tokenUsage.outputTokenCount());

meterRegistry.timer("llm.latency",
"model", model
).record(Duration.ofMillis(latencyMs));

// 计算并记录成本
double cost = calculateCost(model,
tokenUsage.inputTokenCount(),
tokenUsage.outputTokenCount());
meterRegistry.counter("llm.cost.total",
"model", model
).increment(cost);

log.info("LLM call completed: model={}, promptTokens={}, completionTokens={}, " +
"latencyMs={}, cost=${}",
model,
tokenUsage.inputTokenCount(),
tokenUsage.outputTokenCount(),
latencyMs,
String.format("%.4f", cost));
}

@Override
public void onError(ChatModelErrorContext context) {
meterRegistry.counter("llm.requests.total",
"model", "unknown",
"status", "error"
).increment();

log.error("LLM call failed: {}", context.error().getMessage());
}

private double calculateCost(String model, long promptTokens, long completionTokens) {
// 2026 年主流模型定价(每 1M tokens)
return switch (model) {
case "gpt-4o" -> promptTokens * 2.5 / 1_000_000 + completionTokens * 10.0 / 1_000_000;
case "gpt-4o-mini" -> promptTokens * 0.15 / 1_000_000 + completionTokens * 0.6 / 1_000_000;
case "claude-3.5-sonnet" -> promptTokens * 3.0 / 1_000_000 + completionTokens * 15.0 / 1_000_000;
default -> 0.0;
};
}
}

为什么 ChatModelListener 是第一选择? 因为它工作在 LangChain4j 的抽象层,不依赖任何外部系统。即使你还没有部署 OpenTelemetry Collector 或 Prometheus,也能立刻开始收集指标。

注册 Listener 的方式取决于你使用的是声明式还是编程式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 方式一:编程式——构建 ChatModel 时注入
@Bean
public ChatLanguageModel chatModel(AgentObservabilityListener listener) {
return OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o")
.listeners(List.of(listener))
.build();
}

// 方式二:声明式(AiService)——通过 builder 注入
AiService.builder(CustomerServiceAgent.class)
.chatLanguageModel(chatModel)
.chatMemoryProvider(memoryProvider)
.tools(orderTools)
.build();

第二层:OpenTelemetry 集成——生产级分布式追踪

当你需要把 Agent 的调用链路接入已有的 APM 系统(Jaeger、Grafana Tempo、Datadog)时,LangChain4j 的 OpenTelemetry 集成就派上用场了。

LangChain4j 提供了 langchain4j-opentelemetry 模块,它自动为每次 LLM 调用和 Tool 调用创建 Span:

1
2
3
4
5
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-opentelemetry</artifactId>
<version>1.0.0-beta1</version>
</dependency>

关键类是 OpenTelemetryChatModelListener,它把 ChatModel 生命周期事件映射为 OpenTelemetry Span:

1
2
3
4
5
6
7
8
9
10
11
12
13
import dev.langchain4j.opentelemetry.OpenTelemetryChatModelListener;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Tracer;

@Configuration
public class ObservabilityConfig {

@Bean
public OpenTelemetryChatModelListener otelListener(OpenTelemetry openTelemetry) {
Tracer tracer = openTelemetry.getTracer("langchain4j-agent");
return new OpenTelemetryChatModelListener(tracer);
}
}

接入后,在 Jaeger 或 Grafana Tempo 中看到的 Trace 会是这样:

1
2
3
4
5
6
7
Span: agent-interaction (userId=user-456)
├── Span: llm-call (model=gpt-4o, prompt_tokens=1200)
│ └── Attributes: llm.request.messages, llm.response.finish_reason
├── Span: tool-call (name=getOrderStatus)
│ └── Attributes: tool.input, tool.output
├── Span: llm-call (model=gpt-4o, prompt_tokens=800)
└── Span: llm-call (model=gpt-4o, prompt_tokens=600)

和 ChatModelListener 的区别是什么? ChatModelListener 是 LangChain4j 内部的回调机制,适合做自定义的指标计算和日志记录;OpenTelemetry 集成是标准化的分布式追踪协议,适合接入已有的 APM 基础设施。两者可以并存——Listener 做业务指标,OTel 做链路追踪。

Tool 调用的追踪

别忘了 Agent 的另一半是 Tool 调用。LangChain4j 的 Tool 执行也会被自动创建 Span,但你可以在 Tool 方法中手动添加更丰富的上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;

@Component
public class OrderTools {

private final OrderService orderService;
private final Tracer tracer;

public OrderTools(OrderService orderService, Tracer tracer) {
this.orderService = orderService;
this.tracer = tracer;
}

@Tool("根据订单号查询订单状态")
public OrderStatus getOrderStatus(String orderId) {
Span currentSpan = Span.current();
currentSpan.setAttribute("order.id", orderId);

long start = System.nanoTime();
try {
OrderStatus status = orderService.queryStatus(orderId);
currentSpan.setAttribute("order.status", status.getStatus());
return status;
} catch (Exception e) {
currentSpan.setAttribute("error.type", e.getClass().getSimpleName());
throw e;
} finally {
long duration = (System.nanoTime() - start) / 1_000_000;
currentSpan.setAttribute("tool.duration_ms", duration);
}
}
}

Spring AI 的可观测性方案

如果你用的是 Spring AI 而不是 LangChain4j,好消息是 Spring AI 原生集成了 Micrometer Observation API——这是 Spring 生态的标准可观测性抽象层。

自动配置的魔法

Spring AI 的可观测性是开箱即用的。只要你的 classpath 上有 micrometer-tracing-bridge-otel(或 Brave),Spring AI 会自动为每次 Chat Model 调用创建 Observation:

1
2
3
4
5
6
7
8
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

Spring AI 自动创建的 Observation 包含以下信息:

  • Span 名称: chat(或 embeddingimage 等)
  • 高基数标签(High Cardinality Tags): gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens
  • 低基数标签(Low Cardinality Tags): gen_ai.operation.name, gen_ai.system

这里要解释一下”高基数”和”低基数”:高基数标签的值是动态的(比如具体的 token 数量、请求 ID),适合做 Span Attributes;低基数标签的值是固定的几个枚举(比如操作类型),适合做 Metric Tags。Spring AI 遵循了 OpenTelemetry 的 GenAI Semantic Conventions,这是一个专门为 LLM 应用定义的语义标准。

自定义 Observation:给 Agent 加业务维度

默认的 Observation 只覆盖了 LLM 调用层面。要追踪整个 Agent 交互,你需要自定义 Observation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;

@Component
public class AgentInteractionObserver {

private final ObservationRegistry observationRegistry;

public AgentInteractionObserver(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
}

public <T> T observeAgentInteraction(String userId, String agentType,
Supplier<T> interaction) {
return Observation.createNotStarted("agent.interaction", observationRegistry)
.contextualName("agent-" + agentType)
.lowCardinalityKeyValue("agent.type", agentType)
.highCardinalityKeyValue("user.id", userId)
.observe(() -> {
long startTime = System.nanoTime();
try {
T result = interaction.get();
long duration = (System.nanoTime() - startTime) / 1_000_000;
Observation observation = Observation.start("agent.interaction", observationRegistry);
observation.highCardinalityKeyValue("duration_ms", String.valueOf(duration));
observation.stop();
return result;
} catch (Exception e) {
throw e;
}
});
}
}

使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class CustomerServiceAgent {

private final ChatClient chatClient;
private final AgentInteractionObserver observer;

public String handleInquiry(String userId, String message) {
return observer.observeAgentInteraction(userId, "customer-service", () -> {
return chatClient.prompt()
.system("你是客服 Agent,帮助用户处理订单问题")
.user(message)
.call()
.content();
});
}
}

Actuator 端点:实时指标查看

Spring Boot Actuator 自动暴露 AI 相关的指标。配置 management.endpoints.web.exposure.include=metrics 后,访问 /actuator/metrics/chat 就能看到:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "chat",
"measurements": [
{"statistic": "COUNT", "value": 1234.0},
{"statistic": "TOTAL_TIME", "value": 987654.0},
{"statistic": "MAX", "value": 5678.0}
],
"availableTags": [
{"tag": "gen_ai.request.model", "values": ["gpt-4o", "gpt-4o-mini"]},
{"tag": "gen_ai.system", "values": ["openai"]}
]
}

配合 Grafana,你可以做出实时的 Agent 监控大盘。


构建成本监控系统:从指标到告警

可观测性的最终目的是让你能回答两个问题:**”花了多少钱”** 和 **”花得值不值”**。

成本计算的核心公式

1
单次请求成本 = (输入 Token 数 × 输入单价 + 输出 Token 数 × 输出单价) / 1,000,000

以 GPT-4o 为例(2026 年 OpenAI 定价):

  • 输入:$2.50 / 1M tokens
  • 输出:$10.00 / 1M tokens

一个典型的客服交互(3 轮 LLM 调用):

1
2
3
4
轮次1: 1200 prompt + 300 completion = $0.003 + $0.003 = $0.006
轮次2: 1500 prompt + 500 completion = $0.00375 + $0.005 = $0.00875
轮次3: 2000 prompt + 400 completion = $0.005 + $0.004 = $0.009
单次交互总成本: ~$0.024

如果日均 10,000 次交互,月成本 = $0.024 × 10,000 × 30 = $7,200/月

成本告警:防止”一夜破产”

Agent 和传统应用不同,一个 Bug(比如推理死循环)可能在几小时内产生巨额费用。你需要设置多层告警:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Component
public class CostAlertService {

private final MeterRegistry meterRegistry;
private final NotificationService notificationService;

// 告警阈值
private static final double HOURLY_COST_LIMIT = 50.0; // 单小时 $50
private static final double DAILY_COST_LIMIT = 500.0; // 单日 $500
private static final long TOKENS_PER_REQUEST_LIMIT = 10000; // 单次请求 Token 上限

@Scheduled(fixedRate = 60_000) // 每分钟检查
public void checkCostAlerts() {
// 检查小时级成本
double hourlyCost = getMetricValue("llm.cost.total", Duration.ofHours(1));
if (hourlyCost > HOURLY_COST_LIMIT) {
notificationService.sendAlert(
AlertLevel.CRITICAL,
String.format("⚠️ AI Agent 小时成本超限: $%.2f (阈值: $%.2f)", hourlyCost, HOURLY_COST_LIMIT)
);
}

// 检查日级成本
double dailyCost = getMetricValue("llm.cost.total", Duration.ofDays(1));
if (dailyCost > DAILY_COST_LIMIT) {
notificationService.sendAlert(
AlertLevel.CRITICAL,
String.format("🚨 AI Agent 日成本超限: $%.2f (阈值: $%.2f)", dailyCost, DAILY_COST_LIMIT)
);
}
}

@Scheduled(fixedRate = 10_000) // 每 10 秒检查(实时检测异常请求)
public void checkAnomalousRequests() {
// 检测异常大的单次请求(可能是推理死循环)
double maxTokens = meterRegistry.get("llm.tokens.per.request")
.tag("statistic", "MAX")
.gauge().value();
if (maxTokens > TOKENS_PER_REQUEST_LIMIT) {
notificationService.sendAlert(
AlertLevel.WARNING,
String.format("⚠️ 检测到异常大的请求: %d tokens (阈值: %d)", (long) maxTokens, TOKENS_PER_REQUEST_LIMIT)
);
}
}

private double getMetricValue(String metricName, Duration duration) {
// 从 MeterRegistry 获取时间窗口内的累计值
// 具体实现取决于你使用的 Registry 类型
return meterRegistry.get(metricName).counter().count();
}
}

成本分析维度

有了结构化的指标数据,你可以从多个维度分析成本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 按用户分析(找出"高消费"用户)
SELECT user_id, SUM(cost) as total_cost, COUNT(*) as request_count
FROM agent_interactions
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY user_id
ORDER BY total_cost DESC
LIMIT 10;

-- 按 Agent 类型分析(哪种业务最费钱)
SELECT agent_type, AVG(cost) as avg_cost, AVG(total_tokens) as avg_tokens
FROM agent_interactions
GROUP BY agent_type;

-- 按模型分析(是否该降级模型)
SELECT model, COUNT(*) as calls, SUM(cost) as total_cost,
AVG(completion_tokens) as avg_output
FROM llm_calls
GROUP BY model;

这些分析会告诉你:是不是 80% 的请求其实用 GPT-4o-mini 就够了?是不是某个 Tool 的返回值太长导致 LLM 处理成本飙升?


生产环境的可观测性架构

把上面的组件组合起来,一个完整的 Agent 可观测性架构长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─────────────────────────────────────────────────┐
│ AI Agent 应用 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ ChatModel│ │ Tools │ │ Memory/RAG │ │
│ │ Listener │ │ Tracing │ │ Metrics │ │
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
│ │ │ │ │
│ └──────┬───────┴───────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ OpenTelemetry │ │
│ │ Collector │ │
│ └────────┬────────┘ │
└──────────────┼────────────────────────────────────┘

┌──────────┼──────────┐
│ │ │
▼ ▼ ▼
┌──────┐ ┌────────┐ ┌────────┐
│Jaeger│ │Prometheus│ │ Loki │
│Trace │ │Metrics │ │ Logs │
└──┬───┘ └───┬────┘ └───┬────┘
│ │ │
└──────────┼────────────┘

┌────▼────┐
│ Grafana │
│Dashboard│
└─────────┘

Grafana 大盘设计

一个实用的 Agent 监控大盘应该包含以下面板:

行 1:全局概览

  • 今日总请求数(折线图)
  • 今日总 Token 消耗(折线图)
  • 今日总成本(单值面板,带阈值颜色)
  • P50/P99 响应延迟(折线图)

行 2:LLM 调用详情

  • 各模型调用占比(饼图)
  • Token 消耗分布(直方图)
  • LLM 调用错误率(折线图)
  • 平均推理轮次(折线图)

行 3:Tool 调用监控

  • 各 Tool 调用次数(条形图)
  • Tool 调用延迟 Top 10(条形图)
  • Tool 调用失败率(折线图)

行 4:用户分析

  • Top 10 高消费用户(条形图)
  • 按用户类型的成本分布(饼图)
  • 异常请求列表(表格)

源码级解读:LangChain4j Listener 机制的内部实现

要真正用好 ChatModelListener,需要理解它在 LangChain4j 内部是怎么被触发的。我们看一下关键的调用链路。

当你通过 DefaultAiServices 调用 Agent 时,内部流程是:

1
2
3
4
5
6
7
AiService.invoke()
→ DefaultAiServices.create() // 生成代理对象
→ ChatLanguageModel.generate() // 调用 LLM
→ AbstractChatModel.execute() // 统一的调用入口
→ onRequest(Listeners) // 触发所有 Listener 的 onRequest
→ doGenerate() // 实际的 HTTP 调用
→ onResponse(Listeners) // 触发所有 Listener 的 onResponse

核心逻辑在 AbstractChatModel(或具体实现如 OpenAiChatModel)中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 简化版源码分析
public ChatResponse generate(ChatRequest request) {
ChatModelRequestContext requestContext = new ChatModelRequestContext(request, attributes);

// 1. 触发所有 Listener 的 onRequest
for (ChatModelListener listener : listeners) {
try {
listener.onRequest(requestContext);
} catch (Exception e) {
log.warn("Error in ChatModelListener.onRequest", e);
}
}

try {
// 2. 执行实际的 LLM 调用
ChatResponse response = doGenerate(request);

ChatModelResponseContext responseContext = new ChatModelResponseContext(
request, response, attributes);

// 3. 触发所有 Listener 的 onResponse
for (ChatModelListener listener : listeners) {
try {
listener.onResponse(responseContext);
} catch (Exception e) {
log.warn("Error in ChatModelListener.onResponse", e);
}
}

return response;
} catch (Exception e) {
ChatModelErrorContext errorContext = new ChatModelErrorContext(e, attributes);

// 4. 出错时触发 onError
for (ChatModelListener listener : listeners) {
try {
listener.onError(errorContext);
} catch (Exception ex) {
log.warn("Error in ChatModelListener.onError", ex);
}
}
throw e;
}
}

关键设计点

  1. attributes Map 在整个生命周期中共享onRequest 阶段写入的数据,onResponseonError 阶段能读到。这就是为什么我们可以用 context.attributes().put("startTime", ...) 来传递计时信息。

  2. Listener 异常不影响主流程:每个 Listener 的调用都被 try-catch 包裹,一个 Listener 报错不会阻断其他 Listener 或 LLM 调用。这很重要——你的监控代码不应该拖垮业务。

  3. Listener 是同步执行的:在当前版本(1.0.0-beta1)中,Listener 是在调用线程中同步触发的。如果你的 Listener 做了耗时操作(比如写数据库),会增加 LLM 调用的总延迟。最佳实践是 Listener 里只做内存操作(更新指标计数器),把持久化推到异步线程。


实战:从零搭建 Agent 监控系统

把上面的所有组件串起来,我们来构建一个完整的监控方案。

第一步:依赖配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- LangChain4j + OpenTelemetry -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>1.0.0-beta1</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-opentelemetry</artifactId>
<version>1.0.0-beta1</version>
</dependency>

<!-- Micrometer + Prometheus -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>

<!-- OpenTelemetry Exporter -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

<!-- Spring Boot Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

第二步:配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# application.yml
management:
endpoints:
web:
exposure:
include: health, metrics, prometheus
metrics:
tags:
application: ai-agent-service
tracing:
sampling:
probability: 1.0 # 开发环境 100% 采样,生产环境调低

# OpenTelemetry 导出配置
otel:
exporter:
otlp:
endpoint: http://otel-collector:4317
service:
name: ai-agent-service

# Agent 成本告警阈值
agent:
cost:
hourly-limit: 50.0
daily-limit: 500.0
per-request-token-limit: 10000

第三步:完整配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
@EnableScheduling
public class AgentObservabilityConfig {

@Bean
public ChatModelListener chatModelListener(MeterRegistry meterRegistry) {
return new AgentObservabilityListener(meterRegistry);
}

@Bean
public ChatLanguageModel chatModel(ChatModelListener listener) {
return OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o")
.listeners(List.of(listener))
.build();
}

@Bean
public MeterFilter costTagFilter() {
// 为所有 LLM 相关指标添加应用标签
return MeterFilter.commonTags(Tags.of("component", "ai-agent"));
}
}

第四步:验证

启动应用后,验证指标是否正常采集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. 检查 Prometheus 端点
curl -s http://localhost:8080/actuator/prometheus | grep llm_

# 应该看到类似:
# llm_requests_total{model="gpt-4o",status="success"} 42.0
# llm_tokens_total{model="gpt-4o",type="prompt"} 50400.0
# llm_tokens_total{model="gpt-4o",type="completion"} 25200.0
# llm_latency_seconds_sum{model="gpt-4o"} 33.6
# llm_cost_total{model="gpt-4o"} 0.378

# 2. 检查健康端点
curl -s http://localhost:8080/actuator/health | jq .

# 3. 发送一个测试请求,观察指标变化
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: application/json" \
-d '{"userId": "test-user", "message": "你好"}'

生产环境最佳实践与踩坑指南

踩坑一:Listener 里的阻塞操作

问题:在 ChatModelListener 的 onResponse 里做了数据库写入(记录每次 LLM 调用的详细信息),导致 P99 延迟从 2 秒飙升到 5 秒。

原因:Listener 是同步执行的,数据库写入直接叠加在 LLM 调用延迟上。

解决:Listener 里只做内存操作(更新 Micrometer 计数器),把持久化推到异步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class AgentObservabilityListener implements ChatModelListener {

private final MeterRegistry meterRegistry;
private final ExecutorService asyncWriter = Executors.newFixedThreadPool(2);

@Override
public void onResponse(ChatModelResponseContext context) {
// 快速:更新内存中的指标
meterRegistry.counter("llm.tokens.total").increment(tokenUsage.inputTokenCount());

// 异步:持久化详细日志
asyncWriter.submit(() -> {
llmCallLogRepository.save(buildLogFromContext(context));
});
}
}

踩坑二:高基数指标爆炸

问题:把 userId 作为 Metric Tag,导致 Prometheus 时间序列数量暴增,内存占用飙升。

原因:每个唯一的 userId 都会创建一组新的时间序列。10 万用户 × 5 个指标 = 50 万条时间序列。

解决userId 应该用在日志和 Trace 中(高基数数据),不要用在 Metric Tag 中。Metric Tag 只放有限枚举值(模型名、Agent 类型、状态码)。

踩坑三:采样率配置不当

问题:生产环境 100% 采样,Trace 数据量太大,Jaeger 存储撑不住。

解决:分层采样——错误请求 100% 采样,正常请求按比例采样:

1
2
3
4
5
6
7
@Bean
public Sampler traceSampler() {
// 错误 100% 采样,正常 10% 采样
return Sampler.parentBased(
Sampler.traceIdRatioBased(0.1) // 基础采样率 10%
);
}

踩坑四:成本计算模型不准确

问题:用固定的 per-token 价格计算成本,但实际计费包含了缓存命中折扣、批量 API 折扣等,导致成本数据偏差 30%+。

解决:定期(每天或每周)用 Provider 的 Billing API 拉取真实费用,和监控系统计算的费用做对比校准:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Scheduled(cron = "0 0 2 * * *") // 每天凌晨 2 点
public void calibrateCost() {
double providerCost = openAIBillingApi.getDailyCost(LocalDate.now().minusDays(1));
double monitoredCost = getMonitoredCost(LocalDate.now().minusDays(1));
double discrepancy = Math.abs(providerCost - monitoredCost) / providerCost;

if (discrepancy > 0.1) { // 偏差超过 10%
log.warn("Cost calibration off by {}%, provider: ${}, monitored: ${}",
String.format("%.1f", discrepancy * 100),
String.format("%.2f", providerCost),
String.format("%.2f", monitoredCost));
}
}

技术边界与未来展望

什么时候不需要这么重的方案?

  • 开发/测试环境:ChatModelListener + 日志就够了,不需要 OpenTelemetry
  • 个人项目/低流量:Micrometer + Prometheus 的基础指标足够
  • 纯内部工具:成本监控可以简化为每天看一眼 Provider 后台账单

当前方案的局限

  1. **无法追踪 Token 的”质量”**:你能看到消耗了 1000 tokens,但不知道这些 token 有多少是”有效推理”、多少是”废话”
  2. 跨 Provider 的成本标准化困难:OpenAI、Anthropic、Google 的计费模型不同,统一比较需要抽象层
  3. **Agent 行为的”可解释性”**:Trace 能告诉你 Agent 调用了什么工具,但不能告诉你”为什么”做出这个决策

未来方向

  1. AI-native 可观测性:用 AI 分析 Agent 的行为模式,自动发现异常(比如”Agent 突然开始大量调用一个之前很少用的 Tool”)
  2. 成本预测模型:基于历史数据预测未来成本,提前告警
  3. Token 效率优化:通过分析 Trace 数据,自动优化 Prompt 模板减少 Token 消耗
  4. 语义级 Trace:不仅追踪调用链路,还能追踪”推理链路”——Agent 的每一步推理逻辑

总结

Agent 系统的可观测性不是”锦上添花”,而是”生存必需”。没有它,你无法回答三个关键问题:

  1. Agent 在干什么?——链路追踪告诉你每一步推理和工具调用
  2. Agent 花了多少钱?——Token 指标和成本计算告诉你真实的财务影响
  3. Agent 哪里出了问题?——错误率、延迟、异常请求告诉你哪里需要优化

LangChain4j 的 ChatModelListener 是最灵活的观测入口,Spring AI 的 Micrometer 集成是 Spring 生态最自然的选择,OpenTelemetry 是连接一切的标准化协议。三者结合,你就能构建一个覆盖”开发 → 测试 → 生产”全链路的 Agent 可观测体系。

记住那句老话:你无法改善你无法度量的东西。在 Agent 时代,这句话比以往任何时候都更重要。


下一篇我们聊聊 AI Agent 的流式输出与实时交互——当 Agent 需要实时”说话”而不是”说完再回复”时,架构该如何设计?