可观测性与成本治理:一次对话的 17 个观测点

可观测性与成本治理:一次对话的 17 个观测点

一句话结论 :LLM 应用必须按 请求级 Trace + Token 级计量 + 业务级质量 三层观测;成本治理的核心是"可归因",不是单纯换便宜模型。

适用版本 :LangChain4j 1.23.0 / JDK 21


0. 先说结论

LLM 应用最常见的三个"黑盒":

  1. 用户说"变慢了",你不知道是检索、模型、工具还是网络;
  2. 月底账单爆了,你不知道哪个租户/场景/模型烧的;
  3. 回答变差了,你不知道是检索退化、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 计量 输入/输出/缓存 为什么贵
业务质量 采样 + 失败案例 为什么差

落地顺序:

  1. 先给每个请求生成 traceId;
  2. 给检索、模型、工具加 span;
  3. 记录 token usage;
  4. 加成本标签;
  5. 最后做质量采样。

不要一开始就搭大而全的平台;先把"慢/贵/差"三个问题各打通一条链路。


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

三个原则:

  1. 前缀稳定:SystemMessage、工具定义不要频繁变;
  2. 显式失效:Prompt/模型/工具变更时清缓存;
  3. 命中率监控:按模型/租户拆分。
变更 缓存影响
改 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"。按优先级降级:

  1. 非核心场景切小模型;
  2. 关闭 Rerank/长上下文;
  3. 返回缓存/固定话术;
  4. 核心租户保留最低配额。

降级策略要写进配置,并和业务确认,不要技术单方面决定。


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 一个质量下降的排查

现象:用户反馈回答变差,但错误率/延迟正常。

排查顺序:

  1. 检索:Recall/Top-K 是否变化;
  2. Prompt:是否最近改过;
  3. 模型:是否切换/升级;
  4. 数据:文档是否更新/删除;
  5. 评估:黄金集指标是否下降。

结论:质量下降不一定表现为"报错"。没有质量采样,你只能等用户投诉。


9.3 观测数据的保留与合规

数据 保留 脱敏 访问
指标 长期 无 PII 团队
Trace 7--30 天 不含正文 值班
明细日志 7 天 哈希/掩码 受控
质量样本 长期 人工审核 评估组

删除要求:

  • 支持按租户/用户删除;
  • 备份中的副本也要覆盖;
  • 删除动作有审计。

观测数据也是用户数据,不能"为了排错"无限保留。


9.4 观测的投入产出

观测本身也有成本:存储、采集、维护、告警噪音。判断标准不是"指标多不多",而是"能不能支撑决策"。

每加一个指标,问三个问题:

  1. 它会触发什么动作?
  2. 谁能看懂?
  3. 保留多久?

答不上来,就先别加。


9.5 告警的"可行动性"

一条告警如果不能指向明确动作,就只是噪音。上线前检查:谁处理、看什么、做什么、多久恢复;答不上来就删掉或降级为面板,否则就是噪音,请务必认真执行。


10. 上线检查清单

  • 每个请求有 traceId;
  • 模型调用有 Listener;
  • 检索/重排/工具都有 span;
  • Token 按租户/场景/模型拆分;
  • 有日预算与告警;
  • 有缓存命中率;
  • 有质量采样;
  • 日志脱敏;
  • 告警有 runbook。

11. 系列导航

  • 上一篇:A12《Spring Boot 4 集成:@AiService、多模型路由与多租户隔离》
  • 下一篇:A14《LLM 应用安全:Prompt 注入、越权与输入输出护栏》
  • 全系列:《LangChain4j 生产级实战》20 篇路线图(专栏内查看)

相关推荐
金銀銅鐵28 分钟前
带您看懂字节码指令(三):同一行加法,为什么有四种指令
java·jvm
文丑颜不良啊4 小时前
LeetCode2821~2840题目解析
java·leetcode
apcipot_rain5 小时前
C++转Java日记(10)
java·开发语言
简 洁 冬冬6 小时前
泛型方法写法全面辨析:从「能编译」到「能跑」到「能用」
java·泛型
LGL6030A6 小时前
Java学习历程31——初识Spring Boot
java·spring boot·学习
Lansonli8 小时前
大数据Spark(八十八):Spark任务资源之Standalone集群测试
java·服务器·spark
budingxiaomoli8 小时前
Spring AOP
java·spring·java-ee
特立独行的猫A8 小时前
Ionic + 仓颉语言的 cj-tauri 开发框架实战:开发精美移动端 APP
java·后端·python
nice_lcj5208 小时前
LRU 缓存入门:它是什么、怎么用、面试怎么手写
java·数据结构·缓存·面试·lru