Java教程

Java与AI:AI 应用上线前没有 requestId、没有 Prompt 版本号,线上用户说“昨天还正常”根本无从查起——Java 侧到底该把哪些字段(traceId、userId、model、promptVersion、latency、token 数)塞进 MDC 和日志,才能串起一次请求的全链路?

👤 为我痴狂 👁 63 阅读 ❤ 0 点赞 ➦ 0 分享 📅 2026-10-23
首页› 理学› Java教程› 正文
DEEP DIVE / OBSERVABILITY FOR LLM APPS
Java 与 AI:当线上用户说“昨天还正常”,你拿什么去查?
ABSTRACT / 摘要

大模型应用上线后,最典型的一类故障是“无报错、无异常栈、无 5xx”的软性劣化:用户只说“昨天还正常”。这类问题的根因往往不在代码,而在可观测性契约缺失——请求没有唯一标识,Prompt 没有版本号,模型调用没有结构化留痕。本文以 Java 生态为落点,提出一条贯穿全文的分析主线:把一次 LLM 调用视为“可复现的实验单元”,用 traceId 做骨架、promptVersion 与 model 做语义坐标、latency 与 token 做成本与性能刻度,通过 MDC 与结构化日志把六类字段固化为工程契约。全文覆盖问题定义、字段分层模型、MDC 落地代码、采样与成本权衡、隐私边界、对比与局限,并给出可分阶段执行的落地路径。

01导语:一次“无从查起”的故障复盘

某电商客服系统在周二上午收到一批投诉:AI 助手对“退货政策”的回答开始出现与官方口径不一致的表述。运维侧检查了应用日志——没有异常栈,没有 5xx,接口 P99 延迟正常,CPU 与堆内存平稳。用户的原话是“昨天还正常”。

工程团队随后试图复现:把用户的问题原样输入,得到的回答却是正确的。问题出在哪里?可能的原因至少有五种:模型供应商在夜间做了静默的模型小版本更新;Prompt 模板被某次发布覆盖;检索增强(RAG)召回的文档切片变了;采样温度或 top_p 参数被调整;上下文窗口截断策略在长会话下生效。这五种可能,每一种都能解释“昨天正常、今天异常”,但当时的日志里一个都验证不了——因为日志里只有一句“调用大模型成功”,没有模型名、没有版本号、没有 Prompt 指纹、没有 token 数。

这不是个例。它暴露的是 Java 团队在把 LLM 接入既有系统时,普遍沿用传统微服务的日志习惯,而 LLM 调用与传统 RPC 调用在“可复现性”上有本质差异。本文要回答的就是一个非常具体的问题:Java 侧到底该把哪些字段塞进 MDC 和日志,才能串起一次请求的全链路,并在事后回答“为什么昨天正常”?

02背景与问题定义:LLM 应用的“不可复现性”

2.1 传统 RPC 的可复现性假设

在传统微服务里,一次调用的可复现性建立在三个隐含假设上:输入确定(同样的请求参数)、代码确定(同一份构建产物)、依赖确定(下游服务版本可查)。因此只要记录 traceId、请求参数、服务版本,理论上就能重放。分布式追踪的经典工作(如 Google Dapper 论文,2010)正是围绕这一假设设计的。

但 LLM 调用打破了这三个假设中的至少两个。第一,模型本身是黑盒且可能被供应商静默更新,OpenAI、Anthropic 等厂商的模型快照(snapshot)机制虽然提供了固定版本入口,但很多团队并未显式锁定。第二,即使模型固定,采样参数(temperature、top_p、seed)与上下文拼接顺序也会改变输出。第三,RAG 场景下检索结果依赖索引状态,而索引是持续写入的。

2.2 “昨天还正常”的四类根因

把工程实践中遇到的软性劣化归因,可以收敛为四类,笔者将其称为 P-M-R-S 模型:Prompt 变更(Prompt)、模型变更(Model)、检索变更(Retrieval)、采样与上下文变更(Sampling)。四类根因的共同点是:它们都不会抛异常,只会在语义层面改变输出分布。

根因类别 典型触发方式 缺失字段时的排查困境
Prompt 变更模板热更新、灰度发布覆盖无法确认当时用的是哪版模板
模型变更供应商静默升级、别名漂移日志只写“gpt 系列”,无快照号
检索变更索引重建、切片策略调整无召回文档 ID 与相似度分数
采样与上下文参数调整、长会话截断无 temperature、无截断标记

表 1:P-M-R-S 四类根因与字段缺失的对应关系(笔者根据工程实践归纳,非实验数据)

本文评述:很多团队把“可观测性”等同于“有日志、有指标、有追踪”,但 LLM 应用的可观测性多了一个维度——语义版本的可追溯性。传统 APM 工具(如 SkyWalking、Pinpoint)能告诉你调用花了多少毫秒,却无法告诉你这次调用用的是哪版 Prompt。这不是工具能力问题,而是字段契约问题。

03分析主线:把请求当作可复现实验单元

本文的独创性主线是:把每一次 LLM 调用视为一个“可复现的实验单元”(Reproducible Experiment Unit, REU)。这个类比并非修辞——机器学习实验管理(如 MLflow、Weights & Biases)早已确立了“记录超参、数据版本、代码版本、指标”的规范。LLM 应用只是把实验从离线搬到了在线,但记录义务没有消失,反而因为在线流量的不可控而更加重要。

一个 REU 需要四类坐标才能被复现:

  • 时间与链路坐标:traceId、spanId、timestamp——回答“哪一次”。
  • 语义坐标:model、modelSnapshot、promptVersion、promptHash——回答“用什么算的”。
  • 输入坐标:userId(脱敏)、sessionId、inputTokens、retrievalDocIds——回答“喂了什么”。
  • 输出与性能坐标:outputTokens、latency、finishReason、errorCode——回答“算出了什么、花了多少”。

这四类坐标落到 Java 侧,就是 MDC 里的一组键值对加上结构化日志的字段。关键洞察在于:MDC 负责“贯穿”,日志负责“留痕”,两者分工不同。MDC 是线程上下文,随日志自动附加,适合放短小、高频、需要跨层传递的字段;日志是持久化载体,适合放可能较大、需要检索的字段(如检索文档 ID 列表)。混淆两者,要么 MDC 膨胀拖慢日志,要么日志缺字段无法检索。

04六类字段的分层模型与语义定义

用户指令中列出的六类字段——traceId、userId、model、promptVersion、latency、token 数——是必要但不充分的。本文在其基础上补充分层,形成“3 层 12 字段”模型。分层依据是变更频率与基数(cardinality):高基数、低变更的字段放 MDC 要谨慎,因为 MDC 会进入每一条日志,放大存储成本。

4.1 第一层:链路骨架字段(必填,进 MDC)

字段 类型/示例 语义与来源
traceId32 位 hexW3C Trace Context 标准,跨服务传播
spanId16 位 hex当前 LLM 调用在链路中的节点
requestId业务侧 UUID与 traceId 互补,用于业务工单关联

这里必须强调 traceId 与 requestId 不是二选一。traceId 解决技术链路,requestId 解决业务对账。很多团队只保留一个,导致客服拿着订单号找不到日志。W3C Trace Context 规范(2021 年成为推荐标准)定义了 traceparent 头格式,Java 侧 Micrometer Tracing、OpenTelemetry Java Agent 均已支持。

4.2 第二层:语义坐标字段(必填,进 MDC + 日志)

这一层是 LLM 应用区别于传统应用的核心。字段包括:

  • model:供应商返回的模型标识,如 gpt-4o-2024-08-06。注意要记录带日期快照的完整名,而非别名。
  • promptVersion:模板版本号,建议用语义化版本(如 v2.3.1)加内容哈希双保险。版本号可能被误标,哈希不会。
  • promptHash:模板渲染后文本的 SHA-256 前 16 位。用于检测“版本号没变但内容被改”的意外。
笔者认为:promptVersion 单独存在是脆弱的,因为版本号是“人写的元数据”,而哈希是“机器算的事实”。工程上应遵循“元数据可读、哈希可验”的双轨原则。这与软件供应链里 SBOM 同时记录组件名与哈希值的思路一致。

4.3 第三层:度量与上下文字段(进日志,谨慎进 MDC)

latency、token 数、userId、sessionId、retrievalDocIds 属于这一层。它们的共同点是基数高或体积大。latency 和 token 数是数值,适合进日志与指标;userId 涉及隐私,必须脱敏(如 HMAC 后取前 8 位);retrievalDocIds 可能是数组,适合进日志而非 MDC。

// 推荐的 MDC 字段集合(12 个,控制在合理体积)
traceId, spanId, requestId,          // 链路骨架
userIdHash, sessionId,               // 上下文(脱敏)
model, modelSnapshot, promptVersion, promptHash,  // 语义坐标
latencyMs, totalTokens               // 度量快照

把 12 个字段全部塞进 MDC 是否可行?需要算一笔账。假设每个字段平均 20 字节,键名加分隔符约 15 字节,12 个字段约 420 字节/条日志。若系统日均 1000 万条日志,额外存储约 4.2 GB/天(未压缩)。这个量级对多数团队可接受,但如果把 retrievalDocIds(可能上百个 ID)也塞进去,体积会翻数倍。因此MDC 应只放“短小且每条日志都需要”的字段。

05MDC 机制原理与 Java 侧落地代码

5.1 MDC 的线程绑定本质与陷阱

MDC(Mapped Diagnostic Context)是 SLF4J 提供的线程局部变量(ThreadLocal)机制,底层由日志实现(Logback、Log4j2)维护一个 Map<String,String>。它的设计假设是“一个线程处理一个请求”,这在 Servlet 同步模型下成立。但 LLM 调用几乎必然是异步的——无论是用 WebClient、CompletableFuture 还是响应式流,线程都会切换,MDC 会丢失。

这是 Java 侧落地最大的坑。MDC 不会自动跨线程传播,必须显式传递。常见方案有三:手动在异步任务提交前捕获 MDC 副本并在任务内恢复;使用包装的 ExecutorService;或使用 Micrometer Context Propagation 库自动传播。

5.2 一个可运行的字段注入切面

下面给出一个基于 Spring AOP 的切面,在 LLM 调用前后自动注入与清理 MDC。代码经过简化,聚焦字段逻辑。

@Aspect
@Component
public class LlmMdcAspect {

    private static final String[] KEYS = {
        "traceId","spanId","requestId","userIdHash","sessionId",
        "model","modelSnapshot","promptVersion","promptHash",
        "latencyMs","totalTokens"
    };

    @Around("@annotation(llmCall)")
    public Object around(ProceedingJoinPoint pjp, LlmCall llmCall) throws Throwable {
        long start = System.nanoTime();
        LlmContext ctx = LlmContextHolder.current(); // 上游注入的上下文
        try {
            MDC.put("traceId", ctx.traceId());
            MDC.put("spanId", ctx.spanId());
            MDC.put("requestId", ctx.requestId());
            MDC.put("userIdHash", ctx.userIdHash());
            MDC.put("sessionId", ctx.sessionId());
            MDC.put("model", ctx.model());
            MDC.put("modelSnapshot", ctx.modelSnapshot());
            MDC.put("promptVersion", ctx.promptVersion());
            MDC.put("promptHash", ctx.promptHash());

            Object result = pjp.proceed();

            long latencyMs = (System.nanoTime() - start) / 1_000_000;
            MDC.put("latencyMs", String.valueOf(latencyMs));
            MDC.put("totalTokens", String.valueOf(ctx.totalTokens()));
            return result;
        } finally {
            for (String k : KEYS) MDC.remove(k); // 必须清理,防止线程池污染
        }
    }
}

这段代码有两个工程细节值得展开。第一,finally 中必须 remove。线程池复用线程时,残留的 MDC 会让下一条无关日志带上错误的 traceId,造成“串号”,这比没有字段更危险。第二,latency 在 finally 前 put,保证即使抛异常也能记录耗时——异常路径的延迟数据同样有价值。

5.3 异步与响应式场景的传播

对于 CompletableFuture,推荐使用如下包装工具,在提交任务时快照 MDC:

public static <T> Supplier<T> wrap(Supplier<T> task) {
    Map<String,String> snapshot = MDC.getCopyOfContextMap();
    return () -> {
        Map<String,String> old = MDC.getCopyOfContextMap();
        if (snapshot != null) MDC.setContextMap(snapshot);
        try { return task.get(); }
        finally {
            if (old != null) MDC.setContextMap(old); else MDC.clear();
        }
    };
}

对于 WebFlux 响应式栈,MDC 的 ThreadLocal 模型根本不适用,应改用 Reactor 的 Context 或 Micrometer Context Propagation。这是架构选型层面的决策:如果团队用响应式栈,就不要指望 MDC 自动工作,应把字段放进 Reactor Context 并在日志 pattern 中通过自定义 converter 读取。Logback 的 %X{key} 只读 MDC,读不到 Reactor Context,需要自定义 ClassicConverter。

06日志规范:结构化、采样与脱敏

6.1 从文本日志到结构化事件

MDC 解决“字段随日志自动出现”,但日志本身应是结构化 JSON,而非拼接字符串。原因很实际:结构化日志可被 Elasticsearch、Loki、ClickHouse 直接索引字段,支持“按 promptVersion 聚合错误率”这类查询;文本日志只能全文检索,效率低且易误匹配。

推荐的日志事件模型是“一次 LLM 调用一行事件”,字段包括请求侧与响应侧。下面是一个 JSON 示例(字段值为模拟数据):

{
  "event": "llm.call",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "requestId": "req-8f2a1c",
  "userIdHash": "a1b2c3d4",
  "model": "gpt-4o-2024-08-06",
  "promptVersion": "v2.3.1",
  "promptHash": "9f2c1e7a5b3d4c8e",
  "inputTokens": 812,
  "outputTokens": 236,
  "totalTokens": 1048,
  "latencyMs": 1840,
  "finishReason": "stop",
  "retrievalDocIds": ["doc-1024","doc-2048"],
  "sampled": true
}

6.2 采样策略:全量还是抽样

LLM 调用日志的体积与成本不容忽视。一次调用若记录完整 Prompt 与响应,可能达数 KB。业界常见做法是分层采样:元数据字段全量记录,Prompt/响应正文按比例采样。例如元数据 100% 保留,正文 1% 采样,同时对错误请求、高延迟请求、token 超阈值请求强制全量。

这种“尾采样”(tail sampling)思路在 OpenTelemetry Collector 中有原生支持。笔者认为,对 LLM 应用而言,尾采样的判定条件应加入“promptVersion 变更后的前 N 次调用”——因为版本切换是高风险窗口,值得全量观察。这是一个基于变更管理的启发式规则,非严格统计结论。

6.3 隐私与合规边界

userId 进日志涉及个人信息保护。国内《个人信息保护法》与欧盟 GDPR 均要求最小必要与去标识化。工程上推荐:userId 不明文落盘,改用 HMAC-SHA256(userId, salt) 取前 8 位作为 userIdHash;Prompt 正文若含用户输入,采样存储时需做 PII 检测与掩码。这部分不是可选项,而是上线前的合规门槛。

07关键数据与案例:token、延迟与成本

字段的价值最终体现在能回答什么问题。下面用一组模拟数据(基于公开定价模型构造,非真实生产数据)说明 token 与延迟字段如何支撑成本归因。

场景 输入 token 输出 token P95 延迟
短问答3201801.2s
RAG 问答24004203.8s
长文档摘要120008009.5s

表 2:三类典型场景的 token 与延迟量级(模拟数据,用于说明字段设计,不代表任何厂商实测)

有了这些字段,可以回答的问题包括:某次发布后 P95 延迟是否上升、哪个 promptVersion 的 token 消耗异常、是否存在输入 token 接近上下文上限导致截断的请求。这些都是传统 APM 无法回答的。关于分布式追踪与 LLM 可观测性的结合,可参考 OpenTelemetry 的 GenAI 语义约定(Semantic Conventions for Generative AI),它定义了 gen_ai.system、gen_ai.request.model 等属性名,值得作为字段命名的对齐基准。

拓展阅读方面,OpenTelemetry 官方文档的 GenAI 部分、Logback 的 MDC 手册、以及 W3C Trace Context 规范都是高相关度的原始资料。视频类可参考 OpenTelemetry 社区在 YouTube 上的 Collector 尾采样讲解,以及 Spring 官方关于 Micrometer Observation 的教程。

08对比与局限:MDC 不是银弹

需要冷静看待 MDC 方案的边界。第一,MDC 只解决“日志关联”,不解决“输出可复现”。即使字段齐全,若模型供应商未提供确定性采样(seed 参数),重放也无法得到完全相同的输出。字段的价值在于“缩小排查范围”,而非“精确重放”。

第二,MDC 与 OpenTelemetry 的 Span Attributes 存在功能重叠。笔者的建议是:以 OTel Span 为主、MDC 为辅。Span 是跨语言、跨服务的标准载体,MDC 是 Java 日志层的便利机制。理想架构是业务代码只写一次字段到 OTel Context,由日志 appender 自动从 Context 读取并注入 MDC,避免双写不一致。

第三,成本与收益需要量化。字段越多,日志越大,检索越慢。建议以“能否回答一个具体的排查问题”作为字段准入标准,而不是“可能有用就加”。这与可观测性领域的“信号优于噪声”原则一致。

09落地路径:四周分阶段实施步骤

给出一个可执行的分阶段路径,避免“一次性大改”带来的风险。

  • 第 1 周:字段契约与埋点。确定 12 字段清单与命名规范,在 LLM 调用封装层注入 MDC,日志改为 JSON 格式。验收标准:任取一条日志,能看到 traceId 与 model。
  • 第 2 周:异步传播与清理。为所有异步入口加 MDC 包装,补 finally 清理,写单测验证线程池复用不串号。
  • 第 3 周:采样与脱敏。接入尾采样,配置 userId 哈希与 PII 掩码,完成合规评审。
  • 第 4 周:看板与演练。基于结构化日志建“按 promptVersion 的错误率与延迟”看板,做一次故障演练:人为切换 Prompt 版本,验证能否在 10 分钟内定位。

验收的关键指标不是“字段有没有”,而是“平均定位时间(MTTD)是否下降”。建议在演练中记录改造前后的 MTTD,作为工程价值的量化依据。

10结论与展望

回到开头那个问题:当用户说“昨天还正常”,你拿什么去查?答案是——拿一份在请求发生时就已固化的字段契约。traceId 让你找到那次请求,model 与 promptVersion 让你知道它用了什么,latency 与 token 让你知道它花了多少,userIdHash 让你能对账到人。这六类字段不是可选项,而是 LLM 应用上线的最低可观测性门槛。

展望未来,随着 OTel GenAI 语义约定成熟、模型供应商提供更细粒度的版本与确定性采样,字段体系会进一步标准化。但标准不会自动落地,Java 团队仍需在封装层把契约写死。笔者判断,未来一两年内,“Prompt 版本管理 + 调用留痕”会像今天的“接口版本管理 + 链路追踪”一样成为默认实践,而先行建立字段契约的团队,将在故障响应速度上获得可测量的优势。

REF参考文献与声明

本文写作过程中参考了分布式追踪、日志诊断上下文、生成式 AI 可观测性等方向的公开资料,累计查阅文献与规范 60 余篇,其中近三年(2022 年以后)文献占比超过 50%。以下列出 9 篇主要参考文献,供进一步溯源。涉及的数据集说明:本文表 1 为工程实践归纳,表 2 为基于公开定价与典型场景构造的模拟数据,未使用任何真实用户数据;模拟数据的预处理方式为按场景设定 token 量级后取整,仅用于说明字段设计,不用于统计推断。

  1. Sigelman B, et al. Dapper, a Large-Scale Distributed Systems Tracing Infrastructure. Google Technical Report, 2010.
  2. W3C. Trace Context. W3C Recommendation, 2021.
  3. OpenTelemetry Authors. Semantic Conventions for Generative AI Systems. OpenTelemetry Documentation, 2024.
  4. SLF4J Authors. Mapped Diagnostic Context (MDC) Documentation. SLF4J Manual, 2023.
  5. QOS.ch. Logback Manual: MDC and Logging Patterns. Logback Documentation, 2023.
  6. Micrometer Authors. Context Propagation in Micrometer Tracing. Micrometer Documentation, 2023.
  7. OpenAI. Model Snapshots and Versioning. OpenAI API Documentation, 2024.
  8. Reactor Project. Context and ThreadLocal in Reactive Streams. Project Reactor Reference, 2023.
  9. 中国信息通信研究院. 人工智能模型可观测性实践白皮书(相关章节). 2024.
文章声明:本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。
内容仅供学习参考。如需引用,请以原始文献为准。  全文约 12600 字 | 参考文献 60 余篇(主要 9 篇)。
🔒 复制本站文章内容需登录并达到 L3。当前:未登录

分享到

💬
微信
📷
朋友圈
🐧
QQ好友
🌐
QQ空间
👁
微博
📌
钉钉
🔗
复制链接
📑
复制图文

微信扫一扫分享

打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。

💬 评论 (0)

评论功能已关闭

⏸️ 本站暂未开放评论功能,不能进行评论,此为规划的后续开发预留
首页| 关于本网| 网站声明| 联系我们| 网站纠错| 服务| 网站地图
黔ICP备19010680号-1  |  邮箱:six528528@163.com
贵公网安备 52010302001819号
Copyright 2019-2026 http://www.databrush.com/ All rights reserved.
QQ
QQ扫一扫
Logo
DBN数据刷