当前位置:首页 > 文章列表 > 科技周边 > 人工智能 > LLM 请求怎样关联 trace id 与 token 统计

LLM 请求怎样关联 trace id 与 token 统计

来源:17golang原创 2026-10-18 11:49:08 0浏览 收藏

LLM 请求一旦经过网关、业务服务和模型适配器,单看某一行日志很难回答三个问题:这次调用属于哪条用户请求链路、实际消耗了多少 token、异常发生在模型调用还是业务处理?比较稳妥的做法是把它拆成两条线:用 trace_id 关联请求,用响应里的 usage 统计 token,再把两者作为低敏元数据写入结构化日志。

本文使用 Go 的 context.Context 和 log/slog 演示这条链路。OpenTelemetry 官方地址:https://opentelemetry.io/docs/concepts/context-propagation/;Hugging Face Transformers 日志文档地址:https://huggingface.co/docs/transformers/main_classes/logging。示例不记录提示词、模型正文、API 密钥或用户隐私。

核心原则是:span 负责“这次调用在哪条链路上”,usage 负责“这次调用用了多少 token”,结构化日志负责“让两者可以被一起检索”。不要把提示词或响应正文当作关联键。

先把 trace 和 token 统计分成两件事

trace_id 标识一次跨服务的完整请求,span_id 标识其中一个局部操作。入口服务创建或接收 trace,调用 LLM 时再创建一个子 span;这样一次请求里可以区分“排队”“业务编排”和“模型调用”三个阶段。

token 统计来自模型供应商的响应,常见字段是输入 token、输出 token 和总 token,但不同接口可能使用 prompt_tokens、completion_tokens,也可能使用 input_tokens、output_tokens。因此不要直接把某个供应商的响应结构扩散到业务日志,而是先转成自己的统一结构。

字段用途是否建议落日志
trace_id关联一次请求链路建议
span_id定位 LLM 子操作建议
model按模型比较延迟与用量建议
input/output/total_tokens统计调用用量建议
prompt、completion、API key请求内容和凭据不要落日志

在请求上下文中创建 LLM span

OpenTelemetry 的上下文传播用于让不同服务产生的 trace、metric 和 log 可以关联。Go 代码里最重要的习惯是:不要在 LLM 适配器内部重新使用 context.Background(),而是接收上游传来的 ctx,并从它创建子 span。

入口服务到模型服务之间传递 trace_id 和 span_id 的静态结构说明图
图1:trace_id 与 span_id 随请求上下文穿过 LLM 调用链,安全元数据单独记录;这是静态结构说明图,不是运行截图。
package llmobs

import (
    "context"
    "log/slog"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/trace"
)

var tracer = otel.Tracer("example/llm-client")

// CallLLM 在现有请求上下文上创建子 span,只把低敏结果写入日志。
func CallLLM(ctx context.Context, logger *slog.Logger, model string) error {
    ctx, span := tracer.Start(ctx, "llm.request")
    defer span.End() // 无论成功或失败都结束 span,避免链路悬挂。

    started := time.Now()
    err := doProviderRequest(ctx, model) // 这里代表具体 SDK 调用。
    status := "ok"
    if err != nil {
        status = "error"
        span.RecordError(err) // 错误写入 span,但不把提示词塞进属性。
    }

    sc := trace.SpanContextFromContext(ctx)
    logger.InfoContext(ctx, "llm request finished",
        slog.String("trace_id", sc.TraceID().String()),
        slog.String("span_id", sc.SpanID().String()),
        slog.String("model", model),
        slog.String("status", status),
        slog.Int64("latency_ms", time.Since(started).Milliseconds()),
    )
    return err
}

func doProviderRequest(ctx context.Context, model string) error {
    // 实际项目在这里传入 ctx,让 HTTP 客户端继续传播请求上下文。
    return nil
}

这里的 trace.SpanContextFromContext 只读取关联信息,日志本身仍由 InfoContext 接收同一个上下文。即使日志采集器暂时不支持自动关联,也可以显式输出 trace_id 和 span_id,后端按字段检索即可。

把供应商 usage 归一化

统一结构应该描述业务真正关心的结果,而不是复制供应商全部响应。下面的结构只保留模型、输入、输出和总 token;如果某个接口没有返回 usage,就用 UsageKnown=false 表示未知,不能把缺失值悄悄当成零。

不同供应商 token 字段归一化后写入结构化日志的静态关系图
图2:将供应商字段归一化后,与 trace_id、模型和延迟一起写入结构化日志;这是静态数据关系图,不代表某家服务的真实控制台。
package llmobs

import "encoding/json"

// TokenUsage 是跨供应商的最小统计模型,不包含提示词和响应正文。
type TokenUsage struct {
    InputTokens  int64
    OutputTokens int64
    TotalTokens  int64
    UsageKnown   bool
}

// providerResponse 只示意两套常见命名,真实项目按供应商 SDK 映射。
type providerResponse struct {
    Model string `json:"model"`
    Usage struct {
        PromptTokens     *int64 `json:"prompt_tokens"`
        CompletionTokens *int64 `json:"completion_tokens"`
        InputTokens      *int64 `json:"input_tokens"`
        OutputTokens     *int64 `json:"output_tokens"`
        TotalTokens      *int64 `json:"total_tokens"`
    } `json:"usage"`
}

// normalizeUsage 优先读取已返回的总数,缺失时再按输入和输出相加。
func normalizeUsage(raw []byte) (string, TokenUsage, error) {
    var resp providerResponse
    if err := json.Unmarshal(raw, &resp); err != nil {
        return "", TokenUsage{}, err // 响应格式错误不能伪装成零用量。
    }

    var usage TokenUsage
    switch {
    case resp.Usage.InputTokens != nil || resp.Usage.OutputTokens != nil:
        // Responses 风格字段:缺失的一侧按零处理,但保留已知状态。
        if resp.Usage.InputTokens != nil {
            usage.InputTokens = *resp.Usage.InputTokens
        }
        if resp.Usage.OutputTokens != nil {
            usage.OutputTokens = *resp.Usage.OutputTokens
        }
        usage.UsageKnown = true
    case resp.Usage.PromptTokens != nil || resp.Usage.CompletionTokens != nil:
        // Chat Completions 风格字段:映射为统一命名。
        if resp.Usage.PromptTokens != nil {
            usage.InputTokens = *resp.Usage.PromptTokens
        }
        if resp.Usage.CompletionTokens != nil {
            usage.OutputTokens = *resp.Usage.CompletionTokens
        }
        usage.UsageKnown = true
    }

    if resp.Usage.TotalTokens != nil {
        usage.TotalTokens = *resp.Usage.TotalTokens
    } else if usage.UsageKnown {
        usage.TotalTokens = usage.InputTokens + usage.OutputTokens
    }
    return resp.Model, usage, nil
}

真实 SDK 返回的字段可能不是指针,但“缺失”和“数值为零”仍然是两个概念。若接口是流式响应,usage 往往只在结束事件出现,应该等到流结束再记录一次,而不是把每个增量块的累计值重复相加。

让结构化日志同时带上 trace 和 usage

Go 的 log/slog 支持在上下文中记录结构化属性。完成模型调用后,把归一化结果与 span 标识放在同一条完成日志里,排查时就不必先查一份日志再凭时间戳猜另一份日志。

package llmobs

import (
    "context"
    "log/slog"
    "go.opentelemetry.io/otel/trace"
)

// LogUsage 把调用完成时的统计落成一条可检索记录。
func LogUsage(ctx context.Context, logger *slog.Logger, model string, usage TokenUsage, status string, latencyMs int64) {
    sc := trace.SpanContextFromContext(ctx)
    attrs := []slog.Attr{
        slog.String("trace_id", sc.TraceID().String()),
        slog.String("span_id", sc.SpanID().String()),
        slog.String("model", model),
        slog.String("status", status),
        slog.Int64("latency_ms", latencyMs),
        slog.Bool("usage_known", usage.UsageKnown),
    }
    if usage.UsageKnown {
        // 只有 usage 已确认时才写 token 数,避免误报零用量。
        attrs = append(attrs,
            slog.Int64("input_tokens", usage.InputTokens),
            slog.Int64("output_tokens", usage.OutputTokens),
            slog.Int64("total_tokens", usage.TotalTokens),
        )
    }
    logger.LogAttrs(ctx, slog.LevelInfo, "llm usage recorded", attrs...)
}

日志字段建议保持稳定:trace_id 用于聚合,model 用于比较,status 和 latency_ms 用于故障分层,三类 token 用于用量统计。不要把完整响应对象作为一个 JSON 字段写入,否则很容易把正文、工具调用参数或供应商内部信息一起带进日志。

流式响应和重试要怎样记账

流式请求最容易出现重复统计。中间事件可能只有增量文本,结束事件才带最终 usage;推荐以结束事件为准,在同一个 span 上写一次最终 usage。若流中断没有 usage,就记录 usage_known=false、错误类型和是否重试,不要用本地字符串长度估算 token 后冒充供应商账单。

重试也要先定口径:同一用户请求产生两个模型调用时,可以让每次尝试各自拥有一个子 span,并用 parent_trace_id 或业务 request_id 归到同一次业务请求;成本统计按 attempt 累加,用户请求统计则按顶层 trace 聚合。这样既能看到失败尝试,也不会把一次重试误认为一次成功调用。

场景span 记录usage 记录
普通成功一个 LLM 子 span,状态成功完成时写 input/output/total
流式成功span 延续到结束事件只采纳最终 usage 一次
超时或网络失败记录错误和耗时未知就写 usage_known=false
自动重试每次尝试单独子 span按尝试累加,按顶层 trace 聚合

隐私、采样和成本边界

trace_id 不是秘密,但外部传入的 trace header 不能无条件信任。OpenTelemetry 文档提醒,来自不受信任来源的传播数据可能被伪造;对外请求也要谨慎发送内部 trace、span 或 baggage。工程上可以只接受网关清洗后的上下文,并限制 baggage 的键和值。

日志字段还要遵守三条边界:

  • 不记录提示词、完整响应、文件内容、密钥、Authorization 和用户可识别信息。
  • 为高流量成功请求设置采样,但错误、超时和用量异常可以提高保留比例。
  • 为单次请求和按模型的 token 总量设置阈值,发现异常时告警,而不是在日志里追加更多正文。

如果下游日志系统无法保证长期保留,可以把 trace/span 与 usage 汇总到指标或专门的成本表,但仍要保留一个可回查的关联键。目标不是收集越多越好,而是让一次调用能回答“从哪里来、用了多少、为什么失败”。

常见问题

只有 request_id,没有 OpenTelemetry 也能做吗?

可以。先用业务 request_id 贯穿服务,再逐步替换为 trace/span 语义。只是跨服务父子关系、采样和后端展示需要自己维护,规模变大后更容易出现字段不一致。

为什么不能把 prompt 哈希当成 trace_id?

两者语义不同。相同 prompt 可能属于不同用户请求,哈希也可能带来重放和隐私推断风险;trace_id 应表示一次链路,不能表示请求内容。

usage 缺失时应该填 0 吗?

不应该。零可能代表确实没有 token,也可能代表流式结束事件没有到达。用 usage_known=false 把“未知”表达出来,统计时再决定是否排除。

把 token 统计放在 span 属性里可以吗?

可以作为补充,但不要只依赖 span 属性。结构化日志更适合按模型、状态和时间窗口聚合;span 则适合查看一次调用在完整链路中的位置。两者使用同一个 trace_id,查询体验更稳定。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
AI 应用怎样把输出过滤与业务规则分开AI 应用怎样把输出过滤与业务规则分开
上一篇
AI 应用怎样把输出过滤与业务规则分开
Hugging Face Hub 怎样用 commit hash 固定模型文件版本
下一篇
Hugging Face Hub 怎样用 commit hash 固定模型文件版本
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    521次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    578次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    604次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    554次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    375次使用