可观测性与成本治理:一次对话的 17 个观测点
一句话结论 :LLM 应用必须按 请求级 Trace + Token 级计量 + 业务级质量 三层观测;成本治理的核心是"可归因",不是单纯换便宜模型。
适用版本 :LangChain4j
1.23.0/ JDK21
0. 先说结论
LLM 应用最常见的三个"黑盒":
- 用户说"变慢了",你不知道是检索、模型、工具还是网络;
- 月底账单爆了,你不知道哪个租户/场景/模型烧的;
- 回答变差了,你不知道是检索退化、Prompt 改动还是模型升级。
解决方式不是"多打日志",而是三层观测:
text
请求级 Trace:一次对话经过哪些阶段、各花多久
Token 级计量:输入/输出/缓存/工具,各自多少成本
业务级质量:答案是否正确、引用是否可信、用户是否满意
1. 一次对话的 17 个观测点
| # | 观测点 | 用途 |
|---|---|---|
| 1 | traceId / requestId | 串联全链路 |
| 2 | tenantId / userId / conversationId | 归因到租户/会话 |
| 3 | 输入长度/Token | 成本与上下文 |
| 4 | 检索耗时 | 定位 RAG 慢 |
| 5 | 召回块数/Top-K | 检索行为 |
| 6 | 重排耗时 | 定位 Rerank 慢 |
| 7 | Prompt 总 Token | 成本 |
| 8 | 模型名/版本 | 归因到模型 |
| 9 | 模型耗时 | 主要延迟 |
| 10 | 首 Token 延迟 | 体验 |
| 11 | 工具调用次数 | Agent 行为 |
| 12 | 工具耗时/错误 | 工具问题 |
| 13 | 输出 Token | 成本 |
| 14 | 总 Token/成本 | 账单 |
| 15 | 缓存命中 | 降本效果 |
| 16 | 错误类型/重试 | 稳定性 |
| 17 | 结束原因(stop/length/tool) | 回答质量 |
不需要一次全上。先做 1--3、7--10、13--14,已经能回答 80% 的问题。
1.1 三层观测的最小落地
| 层 | 最小实现 | 先解决什么 |
|---|---|---|
| 请求 Trace | traceId + 阶段耗时 | 为什么慢 |
| Token 计量 | 输入/输出/缓存 | 为什么贵 |
| 业务质量 | 采样 + 失败案例 | 为什么差 |
落地顺序:
- 先给每个请求生成 traceId;
- 给检索、模型、工具加 span;
- 记录 token usage;
- 加成本标签;
- 最后做质量采样。
不要一开始就搭大而全的平台;先把"慢/贵/差"三个问题各打通一条链路。
1.2 采样与保留策略
| 数据 | 采样 | 保留 | 原因 |
|---|---|---|---|
| 请求指标 | 100% | 长期 | 容量/成本 |
| Trace | 10%--100% | 7--30 天 | 排错 |
| 完整 Prompt/回答 | 1%--5% | 7 天 | 隐私/成本 |
| 质量评分 | 5%--10% | 长期 | 趋势 |
| 错误日志 | 100% | 30 天 | 排错 |
原则:指标全量、明细采样、敏感数据不落盘。全量存 Prompt 既贵又危险。
1.3 观测与评估的关系
| 体系 | 回答 | 数据 |
|---|---|---|
| 可观测 | 发生了什么 | 指标/Trace/日志 |
| 评估 | 质量好不好 | 黄金集/Judge/人工 |
| 业务 | 有没有价值 | 转化/满意度/转人工 |
三者缺一不可:只有观测,不知道好坏;只有评估,不知道线上发生了什么;只有业务,无法定位。
2. 用 ChatModelListener 做请求级埋点
java
public class LlmMetricsListener implements ChatModelListener {
@Override
public void onRequest(ChatModelRequestContext ctx) {
// 记录 traceId、模型名、消息数、工具数
}
@Override
public void onResponse(ChatModelResponseContext ctx) {
// 记录 token usage、耗时、finish reason
}
@Override
public void onError(ChatModelErrorContext ctx) {
// 记录错误类型、重试次数
}
}
接入:
java
ChatModel model = OpenAiChatModel.builder()
.listeners(List.of(new LlmMetricsListener()))
.build();
注意事项:
- 回调里不要做阻塞 I/O;
- 不记录完整 Prompt/回答,记录长度/哈希;
- 失败也要记录,否则"错误率"永远为 0;
- listener 自身异常不能影响主流程。
2.1 最小埋点实现
java
public class LlmMetricsListener implements ChatModelListener {
private final MeterRegistry registry;
@Override
public void onRequest(ChatModelRequestContext ctx) {
registry.counter("lc4j.chat.requests",
"model", modelName(ctx)).increment();
// 记录开始时间/traceId(可放上下文属性)
}
@Override
public void onResponse(ChatModelResponseContext ctx) {
var usage = ctx.chatResponse().tokenUsage();
registry.summary("lc4j.tokens.input").record(usage.inputTokenCount());
registry.summary("lc4j.tokens.output").record(usage.outputTokenCount());
registry.timer("lc4j.chat.duration",
"model", modelName(ctx)).record(elapsed(ctx));
}
@Override
public void onError(ChatModelErrorContext ctx) {
registry.counter("lc4j.chat.errors",
"type", ctx.error().getClass().getSimpleName()).increment();
}
}
关键:失败也要计数。很多团队的"错误率 0"不是系统稳定,而是没埋 onError。
2.2 检索与工具的埋点
| 阶段 | 记录 | 用途 |
|---|---|---|
| 检索 | 耗时、Top-K、命中数、过滤条件 | 定位召回/性能 |
| 重排 | 耗时、候选数、Top-1 变化 | 评估重排 |
| 工具 | 工具名、耗时、结果状态 | 定位工具问题 |
| 记忆 | 读取/写入耗时、消息数 | 定位上下文 |
java
Timer.Sample sample = Timer.start(registry);
try {
List<Content> hits = retriever.retrieve(query);
registry.summary("lc4j.retrieval.hits").record(hits.size());
} finally {
sample.stop(registry.timer("lc4j.retrieval.duration"));
}
工具埋点不要记录完整参数/返回,记录工具名、哈希、状态、耗时即可。
2.3 17 个观测点的优先级
| 优先级 | 观测点 | 理由 |
|---|---|---|
| P0 | traceId、Token、延迟、错误 | 没有就无法排错 |
| P1 | 检索/工具/重排耗时 | 定位主要阶段 |
| P2 | 缓存、质量采样 | 降本/体验 |
| P3 | finish reason、重试 | 细节优化 |
先把 P0 做全,再谈 P2。很多团队一上来做"质量评分",却没有 traceId,最后无法归因。
2.4 一个最小的观测数据模型
text
traceId, spanId, parentSpanId
stage: retrieval | rerank | chat | tool
startTime, durationMs
status: OK | ERROR | TIMEOUT
inputTokens, outputTokens
model, tenant, scene
有了这 12 个字段,90% 的排错问题都能定位。不要一开始设计几十个字段。
3. Micrometer + OpenTelemetry:指标与链路
| 类型 | 例子 | 用途 |
|---|---|---|
| Counter | lc4j.chat.requests |
请求量 |
| Timer | lc4j.chat.duration |
延迟 |
| DistributionSummary | lc4j.tokens.input |
Token 分布 |
| Gauge | lc4j.circuit.state |
熔断状态 |
| Trace | chat span |
链路 |
java
Timer.builder("lc4j.chat.duration")
.tag("model", modelName)
.tag("tenant", tenantId)
.register(registry)
.record(() -> assistant.chat(question));
Trace 要贯穿:HTTP → 检索 → 重排 → 模型 → 工具。否则你只能看到"总耗时"。
3.1 Trace 上下文怎么传
| 边界 | 传递方式 | 注意 |
|---|---|---|
| HTTP 入口 | Header → MDC | 生成/透传 traceId |
| 线程池 | TaskDecorator | 复制上下文 |
| 异步/响应式 | Context snapshot | 不要依赖 ThreadLocal |
| 工具调用 | 参数/上下文 | 传给下游 |
| 消息队列 | Message Header | 消费端恢复 |
java
MDC.put("traceId", traceId);
try {
assistant.chat(question);
} finally {
MDC.clear();
}
没有贯穿的 traceId,你只能在不同日志里"猜"哪些属于同一次对话。
3.2 指标命名与标签规范
text
lc4j.<domain>.<metric>
例:lc4j.chat.duration / lc4j.retrieval.hits
| 标签 | 是否推荐 | 原因 |
|---|---|---|
model |
✅ | 低基数 |
tenant |
✅(大租户) | 可归因 |
scene |
✅ | 低基数 |
userId |
❌ | 高基数,炸指标库 |
traceId |
❌ | 放日志/Trace |
status |
✅ | 低基数 |
高基数标签会让监控系统崩溃。用户/请求级信息放日志与 Trace,不要放指标标签。
3.3 日志规范
json
{
"traceId": "tr-001",
"tenantId": "acme",
"scene": "support",
"event": "chat.completed",
"model": "deepseek-chat",
"inputTokens": 1200,
"outputTokens": 300,
"latencyMs": 1800,
"status": "OK"
}
三个原则:
- 结构化 JSON,不要拼字符串;
- 字段名统一,便于检索/聚合;
- 不记录完整 Prompt/回答,记录长度/哈希。
日志是给人排错用的,不是"越多越好"。
4. Token 成本模型
text
单请求成本 = 输入 Token × 输入单价
+ 输出 Token × 输出单价
+ 缓存写入/读取成本
| 成本项 | 影响因素 | 优化 |
|---|---|---|
| 输入 Token | 上下文、RAG、历史 | 压缩、裁剪、缓存 |
| 输出 Token | 回答长度 | 限制 maxTokens、Prompt 约束 |
| 缓存 | Prompt Caching | 命中率 |
| 工具 | 工具返回体 | 结构化、截断 |
成本要按 租户/场景/模型 三个维度拆分。只算总量,无法治理。
4.1 成本归因与预算分摊
| 维度 | 用途 | 标签 |
|---|---|---|
| 租户 | 计费/配额 | tenant |
| 场景 | 产品分析 | scene |
| 模型 | 选型/降本 | model |
| 环境 | 预发/生产 | env |
| 版本 | 回归对比 | version |
text
租户月成本 = Σ(该租户各请求 Token × 单价)
场景成本 = Σ(该场景各请求 Token × 单价)
成本看板要支持"下钻":总账 → 租户 → 场景 → 请求。
没有下钻,你只能看到"这个月很贵",不知道贵在哪。
4.2 成本优化的优先级
| 顺序 | 动作 | 影响 | 风险 |
|---|---|---|---|
| 1 | 减少上下文(压缩/Top-K) | 高 | 召回下降 |
| 2 | 缓存 | 高 | 命中率不稳定 |
| 3 | 路由到小模型 | 中高 | 质量下降 |
| 4 | 限制输出长度 | 中 | 回答不完整 |
| 5 | 换供应商 | 中 | 迁移成本 |
先做"不伤质量"的优化(压缩、缓存),再做"可能伤质量"的(小模型、截断)。每一步都要有评估数据。
4.3 成本看板的下钻
text
总成本 → 租户 → 场景 → 模型 → 单请求
| 层 | 看什么 | 动作 |
|---|---|---|
| 总 | 趋势/预算 | 预警 |
| 租户 | Top 消耗 | 配额/套餐 |
| 场景 | 异常增长 | 优化 Prompt/检索 |
| 模型 | 单价/占比 | 路由 |
| 请求 | 异常样本 | 修复 |
没有下钻的成本看板,只能"知道贵",不能"知道为什么贵"。
5. Prompt Caching:降本要先能命中
缓存生效的前提:
- 前缀稳定(SystemMessage/工具定义不变);
- 请求顺序一致;
- 供应商支持缓存。
监控:
- 缓存命中率;
- 命中/未命中的单请求成本;
- 缓存失效原因(Prompt 改动/模型切换)。
没有命中率,缓存只是"理论上省钱"。
5.1 缓存策略与失效
text
cacheKey = model + systemPromptHash + toolsHash + temperature
三个原则:
- 前缀稳定:SystemMessage、工具定义不要频繁变;
- 显式失效:Prompt/模型/工具变更时清缓存;
- 命中率监控:按模型/租户拆分。
| 变更 | 缓存影响 |
|---|---|
| 改 SystemMessage | 全部失效 |
| 改工具描述 | 全部失效 |
| 改温度 | 全部失效 |
| 改用户问题 | 不影响前缀缓存 |
缓存不是"设了就有用",要能证明命中。
5.2 缓存与隐私
缓存可能把用户数据留在供应商侧/本地。注意:
- 含 PII 的请求不要进共享缓存;
- 缓存 key 不含敏感明文;
- 设置 TTL;
- 支持按租户清除;
- 合规场景使用专属缓存/关闭缓存。
省钱的缓存,不能变成数据泄露的通道。
6. 限流、预算与降级
| 层级 | 限制 | 超限行为 |
|---|---|---|
| 租户 | 日 Token/金额 | 降级/拒绝 |
| 用户 | QPS | 429 |
| 请求 | maxTokens/超时 | 截断/超时 |
| 全局 | 并发/预算 | 熔断 |
java
if (!budget.tryConsume(tenantId, estimatedTokens)) {
return degradedAnswer("当前请求较多,请稍后重试");
}
预算要在请求前估算,不要等账单出来才发现。
6.1 预算分配的三种模式
| 模式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 硬上限 | 超预算直接拒绝 | 成本可控 | 影响体验 |
| 软上限 | 超预算降级/告警 | 平衡 | 需要降级链 |
| Showback | 只统计不限制 | 不伤体验 | 成本可能失控 |
建议:
- 对外 SaaS:硬上限 + 套餐;
- 内部工具:软上限 + 告警;
- 新业务:先 showback,摸清分布再限。
预算不是财务数字,是产品策略。
6.2 预算告警的阈值设计
| 阈值 | 动作 | 通知 |
|---|---|---|
| 50% | 观察 | 日报 |
| 80% | 降级非核心/限流 | 值班 |
| 100% | 硬限制/拒绝 | 值班+负责人 |
| 预测超支 | 提前调整 | 财务/产品 |
预算告警要按租户/场景拆分。只有总预算,无法知道谁在烧。
6.3 降级与预算的联动
预算耗尽时,不要直接"全站 500"。按优先级降级:
- 非核心场景切小模型;
- 关闭 Rerank/长上下文;
- 返回缓存/固定话术;
- 核心租户保留最低配额。
降级策略要写进配置,并和业务确认,不要技术单方面决定。
7. 看板与告警
| 面板 | 指标 | 告警 |
|---|---|---|
| 流量 | QPS/错误率 | 错误率突增 |
| 延迟 | P95/首 Token | 超 SLA |
| 成本 | Token/金额 | 日预算 80% |
| 质量 | 采样评分 | 连续下降 |
| 缓存 | 命中率 | 骤降 |
| 租户 | Top 消耗 | 异常租户 |
告警要可执行:每条告警都要有"先看什么、先做什么"。
7.1 告警分级与 runbook
| 级别 | 例子 | 响应 |
|---|---|---|
| P1 | 全站错误率 >20% | 立即回滚/切备用 |
| P2 | 单租户配额打满/延迟翻倍 | 限流/扩容 |
| P3 | 成本接近预算/缓存命中下降 | 当日处理 |
每条告警附 runbook:
text
1. 看哪个面板
2. 最近是否有发布/配置变更
3. 先止血动作
4. 升级联系人
告警不是"发出来就完了",要能指导动作。
7.2 一个告警风暴的处理
现象:模型供应商抖动,10 分钟内收到 500 条相同告警。
根因:
- 按请求告警,没有聚合;
- 没有去重/抑制;
- 告警没有分级。
修复:
- 按 5 分钟窗口聚合;
- 相同指纹去重;
- P1/P2/P3 分级;
- 故障期间静默低级别告警。
告警的目标是"让人行动",不是"证明监控存在"。
7.3 成本与质量的平衡
| 优化 | 成本 | 质量风险 | 需要验证 |
|---|---|---|---|
| 缩短上下文 | 降 | 中 | Recall/Faithfulness |
| 小模型 | 降 | 中高 | 答案正确率 |
| 缓存 | 降 | 低 | 命中率/一致性 |
| 限制输出 | 降 | 中 | 完整性 |
| Rerank | 升 | 降(提升质量) | nDCG/答案 |
任何降本动作都要有质量评估。只看账单,容易把产品做坏。
7.4 成本与质量的联合看板
| 区域 | 指标 | 用途 |
|---|---|---|
| 成本 | Token/金额/租户 | 是否超预算 |
| 延迟 | P95/首 Token | 体验 |
| 质量 | 采样评分/Faithfulness | 是否降质 |
| 结构 | Top-K/上下文长度 | 归因 |
只看成本,容易"降本降质";只看质量,容易"不计成本"。两块面板要放在同一页,变更前后一起看。
8. 常见失败模式
| 现象 | 根因 | 修复 |
|---|---|---|
| 只有总耗时 | 无阶段埋点 | 加 span |
| 成本不可归因 | 无租户/场景标签 | 统一 tag |
| 错误率为 0 | 未记录失败 | onError 埋点 |
| 缓存未命中 | Prompt 前缀变化 | 固定前缀 |
| 告警风暴 | 阈值太敏感 | 分级/聚合 |
| 日志泄露 | 打印全文 | 脱敏/哈希 |
8.1 一次成本异常排查案例
现象:某天成本突然翻倍。
排查:
- 总请求量没变;
- 单请求输入 Token 翻倍;
- 某租户的 RAG Top-K 从 5 被调到 20;
- 配置变更没有走审核。
修复:
- 回滚配置;
- 给 Top-K/上下文加预算上限;
- 配置变更纳入审计与灰度。
成本异常通常不是"模型涨价",而是某个参数/租户/场景悄悄变了。
8.2 一个"日志很多但没用"的案例
现象:每天几十 GB 日志,排错仍然靠猜。
根因:
- 日志是纯文本,没有 traceId;
- 打印完整 Prompt,敏感又难检索;
- 没有指标/截图,只有"开始/结束"。
修复:
- 结构化日志 + traceId;
- 只记录长度/哈希/状态;
- 关键阶段打点;
- 指标看趋势,日志看个案。
日志的价值是"可检索、可关联",不是"量大"。
8.3 一个 Trace 断裂的案例
现象:日志里能看到检索和模型,但无法串成一次请求。
根因:
- 异步线程池没有传递 traceId;
- 工具调用走了独立 HTTP 客户端,没有注入 Header;
- 日志格式不一致。
修复:
- TaskDecorator 复制 MDC;
- HTTP 客户端注入 trace Header;
- 统一日志字段。
Trace 断裂,观测就从"链路"退化成"散点"。
9. FAQ
埋点会不会影响性能?
回调本身开销很小;阻塞 I/O、同步上报才会拖慢。用异步队列/批量上报。
必须上 OpenTelemetry 吗?
不一定。Micrometer + 结构化日志已经能解决大部分问题;跨服务链路再上 OTel。
成本优化先做什么?
先做归因(租户/场景/模型),再做缓存/裁剪/路由。没有归因的降本容易伤质量。
怎么衡量"质量"?
采样 + Judge/人工抽检 + 业务指标(转人工率/满意度)。不要只看 token。
日志要保留多久?
按合规与排错需求定;至少保留聚合指标,明细日志可短期 + 采样。
9.1 可观测性成熟度
| 级别 | 状态 | 特征 |
|---|---|---|
| L0 | 无 | 只有应用日志 |
| L1 | 有指标 | QPS/延迟/错误 |
| L2 | 有 Trace | 阶段耗时 |
| L3 | 有成本归因 | 租户/场景/模型 |
| L4 | 有质量闭环 | 采样+评估+告警 |
先到 L2,再谈成本与质量。没有 Trace,成本归因也很难做准。
9.2 一个质量下降的排查
现象:用户反馈回答变差,但错误率/延迟正常。
排查顺序:
- 检索:Recall/Top-K 是否变化;
- Prompt:是否最近改过;
- 模型:是否切换/升级;
- 数据:文档是否更新/删除;
- 评估:黄金集指标是否下降。
结论:质量下降不一定表现为"报错"。没有质量采样,你只能等用户投诉。
9.3 观测数据的保留与合规
| 数据 | 保留 | 脱敏 | 访问 |
|---|---|---|---|
| 指标 | 长期 | 无 PII | 团队 |
| Trace | 7--30 天 | 不含正文 | 值班 |
| 明细日志 | 7 天 | 哈希/掩码 | 受控 |
| 质量样本 | 长期 | 人工审核 | 评估组 |
删除要求:
- 支持按租户/用户删除;
- 备份中的副本也要覆盖;
- 删除动作有审计。
观测数据也是用户数据,不能"为了排错"无限保留。
9.4 观测的投入产出
观测本身也有成本:存储、采集、维护、告警噪音。判断标准不是"指标多不多",而是"能不能支撑决策"。
每加一个指标,问三个问题:
- 它会触发什么动作?
- 谁能看懂?
- 保留多久?
答不上来,就先别加。
9.5 告警的"可行动性"
一条告警如果不能指向明确动作,就只是噪音。上线前检查:谁处理、看什么、做什么、多久恢复;答不上来就删掉或降级为面板,否则就是噪音,请务必认真执行。
10. 上线检查清单
- 每个请求有 traceId;
- 模型调用有 Listener;
- 检索/重排/工具都有 span;
- Token 按租户/场景/模型拆分;
- 有日预算与告警;
- 有缓存命中率;
- 有质量采样;
- 日志脱敏;
- 告警有 runbook。
11. 系列导航
- 上一篇:A12《Spring Boot 4 集成:@AiService、多模型路由与多租户隔离》
- 下一篇:A14《LLM 应用安全:Prompt 注入、越权与输入输出护栏》
- 全系列:《LangChain4j 生产级实战》20 篇路线图(专栏内查看)