AI 工程基础体系 · 第 30/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

AI 可观测性与成本治理:Trace、Token、TTFT、预算、缓存和降级

生成式 AI 系统的故障,通常不是“模型调用失败”这么简单。一次用户请求可能经过网关、身份校验、检索、重排、提示词组装、模型调用、工具调用、结果校验和流式传输;其中任一环节都可能增加延迟、消耗 Token 或改变最终质量。

因此,生产系统需要同时回答四个问题:

  1. 这次请求经过了哪些组件,在哪个环节变慢或失败?
  2. 消耗了多少输入、输出、缓存和推理 Token?
  3. 延迟是排队、首 Token 生成慢,还是后续输出速度慢?
  4. 在预算、限流、缓存命中和服务降级发生时,系统是否仍然可控?

这里的“可观测性”不是简单记录日志,“成本治理”也不是月底统计账单。两者必须共享同一条请求链路:每一个成本数字都能追溯到调用上下文,每一次降级都能解释其预算原因,每一个质量回归都能关联到模型、提示词、数据和缓存策略。


一、先建立统一对象:一次 AI 请求是什么

在普通 Web 服务中,一个请求通常由 HTTP 请求和若干数据库、RPC 调用组成。AI 请求更复杂,因为模型调用本身具有:

  • 不同输入和输出长度;
  • 流式和非流式两种完成方式;
  • 首 Token 延迟与后续生成速度不同;
  • 重试、取消和工具调用;
  • 可能存在缓存输入、推理 Token、批处理等供应商特有计费项;
  • 输出质量无法只用 HTTP 状态码描述。

可以把一次用户请求抽象为一个根 Trace:

用户请求
└── AI workflow
    ├── 身份认证与预算检查
    ├── 检索 / 重排
    ├── Prompt 组装
    ├── LLM 调用
    │   ├── 第一次尝试
    │   └── 重试
    ├── 工具调用
    ├── 输出校验
    └── 流式传输 / 响应

1. Trace、Span 和事件

Trace 是一次完整操作的因果链。它拥有一个全局唯一的 trace_id

Span 是 Trace 中具有起止时间的操作,例如:

  • http.request
  • retrieval.search
  • llm.call
  • tool.call
  • output.validation

一个 Span 至少应记录:

trace_id
span_id
parent_span_id
开始时间
结束时间
状态:ok / error / cancelled
服务与版本
模型及模型版本
关键业务维度

事件(event) 是 Span 内某个时刻发生的事情,例如:

  • 收到上游响应头;
  • 收到第一个非空输出 Token;
  • 收到工具调用请求;
  • 用户取消连接;
  • 达到输出 Token 上限;
  • 触发预算降级。

事件没有独立持续时间,但对解释 TTFT、取消和流式错误非常重要。

Trace 的因果关系不能只依靠日志时间戳推断。例如两个并发工具调用可能交错完成:

trace_id = t1
├── retrieval span: 100ms ~ 250ms
├── llm span:       300ms ~ 1800ms
└── tool span:      700ms ~ 900ms

如果没有 parent_span_id,仅凭日志时间可能无法判断工具调用属于哪一次模型生成。

2. 不要把所有字段都放进指标标签

指标系统适合低基数维度,例如:

model_family
region
route
status
cache_result
degradation_level

不适合直接作为标签的字段包括:

  • 用户 ID;
  • 完整 Prompt;
  • 完整回答;
  • trace_id
  • 文档内容;
  • 任意 URL。

这些字段放入日志或 Trace 事件,并设置采样、脱敏和访问权限。把 trace_id 作为 Prometheus 标签,会造成时间序列数量随请求数增长,最终让监控系统本身失效。

OpenTelemetry 已有面向生成式 AI 的语义约定,但不同语言 SDK、供应商和版本的字段仍可能变化。工程上应固定自己的内部字段,例如 ai.input_tokensai.output_tokensai.ttft_ms,再把供应商字段映射过来;不能假设所有 API 都返回完全相同的 usage 结构。


二、Token:成本和容量的共同计量单位

1. Token 不等于字符,也不等于词

Token 是模型分词器产生的离散单元。英文中一个单词可能被拆成多个 Token;中文中一个汉字常常接近一个 Token,但这不是规范保证。代码、JSON、URL 和特殊标记往往比自然语言更难压缩。

因此,下面这种估算不可靠:

输入字符数 × 固定比例 = Token 数

准确计算应使用目标模型对应的 tokenizer,或者使用供应商提供的 Token 计数接口。不同模型的 tokenizer 可能不同,不能用一个模型的计数器为所有模型估算。

2. 输入、输出、缓存和推理 Token

常见的计量项包括:

  • 输入 Token:发送给模型的内容,包括系统提示词、用户输入、历史消息、工具定义和检索结果;
  • 输出 Token:模型返回给应用的可见输出;
  • 缓存输入 Token:模型供应商识别并复用的输入部分,可能有不同价格;
  • 推理 Token:模型内部推理使用的 Token,有的模型单独计费或单独返回;
  • 总 Token:供应商定义的汇总值,不能擅自认为等于输入加输出。

一个通用成本模型是:

C=Ipi+Opo+Kpk+Rpr106C = \frac{I \cdot p_i + O \cdot p_o + K \cdot p_k + R \cdot p_r}{10^6}

其中:

  • II:普通输入 Token;
  • OO:输出 Token;
  • KK:缓存输入 Token;
  • RR:推理 Token;
  • pi,po,pk,prp_i,p_o,p_k,p_r:对应的每百万 Token 单价;
  • CC:本次模型调用成本。

如果某供应商没有单独返回 KKRR,就不能把它们凭空拆出来。应记录供应商返回的原始 usage,并在内部标记:

usage_completeness = complete / partial / estimated

estimated 表示估算值,只能用于预算预警,不能作为财务结算的唯一依据。

3. 重试会造成真实重复成本

假设一次请求第一次调用发生超时,但供应商已经生成了 800 个输出 Token,客户端没有收到完整响应,随后重试一次:

第一次:输入 2,000,输出 800,结果客户端视为失败
第二次:输入 2,000,输出 600,成功

实际成本是两次调用之和,而不是成功响应那一次:

Itotal=2000+2000=4000I_{\text{total}} = 2000 + 2000 = 4000

Ototal=800+600=1400O_{\text{total}} = 800 + 600 = 1400

如果只在最终成功的 Span 上记账,成本会被低估,错误率也会被低估。正确做法是:

  • 每次供应商调用都有独立的 attempt_span
  • 根 Trace 聚合所有尝试;
  • 重试原因单独记录;
  • 取消前已经产生的 usage 不应被删除。

4. 完整算例:Token 成本如何聚合

假设价格如下,仅用于说明计算方法:

普通输入:$2 / 1M Token
缓存输入:$0.5 / 1M Token
输出:    $8 / 1M Token

一次工作流包含两个模型调用:

调用 A:普通输入 12,000,缓存输入 3,000,输出 1,500
调用 B:普通输入 4,000,缓存输入 0,输出 800

则:

CA=12000×2+3000×0.5+1500×8106=0.0207C_A = \frac{12000 \times 2 + 3000 \times 0.5 + 1500 \times 8}{10^6} = 0.0207

CB=4000×2+800×8106=0.0144C_B = \frac{4000 \times 2 + 800 \times 8}{10^6} = 0.0144

整条 Trace 的模型成本:

Ctrace=0.0207+0.0144=0.0351C_{\text{trace}} = 0.0207 + 0.0144 = 0.0351

如果系统只统计最终回答的调用 B,会把成本记成 $0.0144,低估约 59%。这也是为什么成本聚合必须以 Trace 为边界,而不是以 HTTP 响应为边界。


三、TTFT:首 Token 延迟和总延迟不是一回事

1. TTFT 的定义

TTFT(Time To First Token) 是从客户端发起模型请求,到客户端收到第一个有效输出 Token 的时间。

在流式系统中,应明确时间点。一个实用定义是:

TTFT=tfirst_nonempty_tokentrequest_sendTTFT = t_{\text{first\_nonempty\_token}} - t_{\text{request\_send}}

其中:

  • trequest_sendt_{\text{request\_send}}:请求真正发送给模型供应商的时间;
  • tfirst_nonempty_tokent_{\text{first\_nonempty\_token}}:收到第一个非空文本增量的时间。

不要把“收到 HTTP 响应头”自动当成首 Token。响应头可能已经到达,但正文尚未返回有效内容。

端到端首 Token 延迟还可以拆成:

TTFTe2e=Tqueue+Tnetwork-up+Tprovider-queue+Tprefill+Tnetwork-downTTFT_{\text{e2e}} = T_{\text{queue}} + T_{\text{network-up}} + T_{\text{provider-queue}} + T_{\text{prefill}} + T_{\text{network-down}}

其中:

  • TqueueT_{\text{queue}}:本地限流器或并发队列等待;
  • Tnetwork-upT_{\text{network-up}}:请求上传;
  • Tprovider-queueT_{\text{provider-queue}}:供应商排队;
  • TprefillT_{\text{prefill}}:模型读取和处理输入;
  • Tnetwork-downT_{\text{network-down}}:首个输出片段返回。

输入 Token 越多,通常会增加 prefill 时间,但具体关系依赖模型架构、硬件和服务实现,不能直接断言“输入 Token 增加一倍,TTFT 也增加一倍”。

2. TTFT 与生成速度

总延迟可以近似写成:

TtotalTTFT+OrdecodeT_{\text{total}} \approx TTFT + \frac{O}{r_{\text{decode}}}

其中:

  • OO:输出 Token 数;
  • rdecoder_{\text{decode}}:生成速度,单位为 Token/s。

两个服务可能有不同的性能特征:

服务 A:TTFT = 300ms,生成速度 = 20 Token/s,输出 200 Token
总时间 ≈ 0.3 + 200/20 = 10.3s

服务 B:TTFT = 1.2s,生成速度 = 60 Token/s,输出 200 Token
总时间 ≈ 1.2 + 200/60 = 4.53s

如果产品强调“用户尽快看到反馈”,A 可能更好;如果产品强调完整答案尽快结束,B 更好。只看平均总延迟会掩盖这两类差异。

应同时监控:

TTFT p50 / p95 / p99
总延迟 p50 / p95 / p99
inter-token latency
output tokens per second
首 Token 超时率
流式中途断开率

3. 流式输出的正确计时边界

流式 Span 的生命周期应覆盖整个流,而不是收到首 Token 就结束:

llm.call span
├── start
├── request_sent
├── first_token       -> TTFT
├── token_delta ...
├── stream_cancelled / stream_error
└── completed         -> total latency, usage

伪代码如下:

import time

def consume_stream(stream):
    start = time.monotonic()
    first_token_at = None
    output_parts = []

    try:
        for event in stream:
            text = getattr(event, "text", "") or ""
            if text:
                if first_token_at is None:
                    first_token_at = time.monotonic()
                    record_metric(
                        "ai_ttft_ms",
                        (first_token_at - start) * 1000,
                    )
                output_parts.append(text)
                send_to_user(text)

        end = time.monotonic()
        record_metric("ai_total_latency_ms", (end - start) * 1000)
        return "".join(output_parts)

    except ClientDisconnected:
        # 已经发出的 Token 仍然产生了真实成本
        record_event("stream_cancelled_by_client")
        raise

这里的 event.text 只是供应商适配层归一化后的字段。不同 API 的流式事件结构不同,不能把这段代码直接当成所有 SDK 的通用接口。适配层应负责把供应商事件转换为:

text_delta
tool_call_delta
usage
completed
error

四、预算:先授权,再执行,再结算

1. 预算不是账单阈值

预算 是在执行前限制资源使用,在执行中监控消耗,并在执行后核算实际成本的机制。

至少需要区分三种预算:

  1. 请求预算:单次请求最多允许多少 Token 或多少钱;
  2. 用户 / 租户预算:某个周期内允许使用的金额;
  3. 系统预算:整个服务、区域或模型池的上限。

如果只在账单产生后报警,预算就只是报表,而不是控制机制。

2. 预算授权的必要性

模型调用前通常只有估算值,调用完成后才有实际 usage。因此需要两个数字:

  • estimated_cost:执行前估算;
  • actual_cost:执行后结算。

假设租户剩余预算为 $0.10,两个并发请求都估算为 $0.08

请求 A 读取余额:0.10,判断足够
请求 B 读取余额:0.10,判断足够
A 执行并扣除 0.08
B 执行并扣除 0.08
最终消耗 0.16,超过预算

这是典型的检查与扣除之间的竞态。预算授权必须原子化,或者使用带版本号的乐观锁。

SQL 示例:

UPDATE tenant_budget
SET reserved_amount = reserved_amount + :estimate,
    version = version + 1
WHERE tenant_id = :tenant_id
  AND reserved_amount + spent_amount + :estimate <= budget_limit;

应用检查受影响行数:

affected_rows = 1:授权成功
affected_rows = 0:预算不足或版本冲突,需要重新读取

授权成功后,调用结束必须执行结算:

实际成本 < 估算成本:释放多余预留
实际成本 > 估算成本:补扣差额或标记超支
调用失败但供应商已产生 usage:按实际 usage 结算
客户端取消:按已产生 usage 结算

3. 完整预算算例

租户月预算为 $10,当前已结算 $8.50,已预留 $0.80,可用预算为:

108.500.80=0.7010 - 8.50 - 0.80 = 0.70

新请求估算成本 $0.60,授权后:

spent_amount     = 8.50
reserved_amount  = 1.40
remaining        = 0.10

实际调用只花费 $0.42,结算释放 $0.18

spent_amount     = 8.92
reserved_amount  = 0.82
remaining        = 0.26

如果实际花费 $0.75,则超出原预留 $0.15。系统需要根据策略:

  • 允许小额超支并记录;
  • 从可用余额扣除;
  • 中止后续工作流;
  • 把租户置为只读或降级状态。

不能假设“估算 Token 上限就是实际成本上限”。供应商计费项、工具调用和重试可能使最终成本超过最初估算。


五、缓存:降低成本,但会改变正确性边界

1. 精确缓存与语义缓存

精确缓存要求规范化后的请求完全一致:

model
system_prompt_version
user_input
conversation_state
tool_schema_version
retrieval_document_ids
generation_parameters

这些字段组合后生成缓存键,例如:

key=H(modelprompt_versionmessagestoolsparameterscontext_version)key = H( model \parallel prompt\_version \parallel messages \parallel tools \parallel parameters \parallel context\_version )

只要其中一个会影响输出的因素发生变化,就应改变键。

语义缓存则根据向量相似度判断两个输入“意思接近”,可能返回之前的答案。它能提高命中率,但正确性条件更严格:相似问题不一定需要相同答案。例如:

  • “今天北京天气如何?”
  • “明天北京天气如何?”

语义上很接近,但答案不能复用。

对涉及时间、权限、库存、价格、账户状态和法律政策的请求,不应仅凭语义相似度复用结果。

2. 应区分应用缓存和供应商前缀缓存

应用缓存是:

命中后不调用模型,直接返回完整结果

它减少了模型调用次数、延迟和成本,但返回的是历史答案。

供应商的 Prompt Prefix Caching 通常是:

仍然调用模型,但复用重复输入前缀的计算或计费

它不会跳过模型生成,也不等价于应用层缓存。两者在 Trace 中应分别记录:

application_cache = hit / miss / bypass
provider_cached_input_tokens

否则容易把供应商缓存命中误报为“请求没有调用模型”。

3. 缓存的失效条件

缓存项至少需要包含:

created_at
expires_at
model_version
prompt_version
data_version
policy_version
tenant_scope

检索增强生成(RAG)尤其需要 data_version。如果知识库文档更新,但缓存键不变,系统会继续返回旧答案。

权限也必须进入缓存边界。一个用户能看到的检索结果,不能被另一个无权用户命中。安全上更稳妥的做法是:

cache_scope = tenant_id + authorization_policy_version

而不是只使用用户问题作为键。

4. 缓存反而可能增加成本的情况

缓存并不总是省钱:

  • 为生成缓存键而发送过大的规范化内容;
  • 缓存命中率低,但每次都执行昂贵的向量检索;
  • 缓存污染导致错误回答,引发重试和人工处理;
  • 缓存答案过期,用户再次追问或触发校正流程;
  • 把高质量模型的答案缓存给低风险和高风险场景,造成质量不匹配。

因此应记录缓存的真实收益:

cache_savings=Cwithout cacheCwith cache\text{cache\_savings} = C_{\text{without cache}} - C_{\text{with cache}}

其中 with cache 还应包含向量检索、缓存存储和校验成本,而不是只比较模型账单。


六、降级:预算和故障驱动的状态机

降级 是在资源、延迟或质量约束无法同时满足时,主动选择较低成本、较低能力或较低时效性的路径。

降级不是简单地“换一个小模型”。它可能包括:

完整 RAG
→ 减少检索文档数量
→ 只使用缓存答案
→ 使用较小模型
→ 禁止工具调用
→ 缩短输出上限
→ 返回结构化摘要
→ 返回明确的暂时不可用

降级策略必须有明确触发条件,否则不同开发者会在不同位置随意修改 Prompt,最终无法解释质量变化。

1. 一个可验证的降级状态机

stateDiagram-v2
    [*] --> Normal
    Normal --> BudgetWarning: 预算使用率 >= 80%
    Normal --> LatencyWarning: TTFT p95 超阈值
    Normal --> ProviderError: 连续错误超过阈值

    BudgetWarning --> ReducedContext: 预算仍可用
    LatencyWarning --> SmallerModel: 延迟持续恶化
    ProviderError --> FallbackModel: 备用模型可用

    ReducedContext --> CacheOnly: 预算不足
    SmallerModel --> CacheOnly: 备用路径可用
    FallbackModel --> CacheOnly: 备用模型失败

    CacheOnly --> Refuse: 无可信缓存
    Normal --> Completed
    ReducedContext --> Completed
    SmallerModel --> Completed
    FallbackModel --> Completed
    CacheOnly --> Completed
    Refuse --> [*]
    Completed --> [*]

每次状态变化都应在 Trace 中记录:

degradation_level
degradation_reason
policy_version
original_route
selected_route
estimated_savings
quality_risk

例如:

{
  "degradation_level": "reduced_context",
  "degradation_reason": "tenant_budget_remaining_low",
  "policy_version": "budget-policy-2025-03",
  "original_route": "large-model-rag-tools",
  "selected_route": "small-model-top3-docs-no-tools",
  "estimated_savings_usd": 0.021
}

2. 降级的反例:无限重试造成雪崩

假设主模型超时后:

  1. 重试主模型;
  2. 调用备用模型;
  3. 备用模型也超时;
  4. 再次重试;
  5. 每次都重新执行检索和工具调用。

这会导致单个用户请求产生多个模型调用,扩大成本和并发压力。正确做法是为整个根请求设置:

deadline
remaining_budget
max_attempts
retryable_error_types

每次重试前同时判断:

当前时间是否超过 deadline?
剩余预算是否足够?
错误是否属于可重试类别?
这次重试是否可能改变结果?

连接失败、限流和供应商暂时不可用通常可能重试;参数错误、权限错误、内容策略拒绝通常不应重试。网络层的超时不等于模型没有生成内容,因此仍需依据返回的 usage 或供应商账单进行结算。

3. 降级不能绕过安全和权限

成本降级可以减少上下文或切换模型,但不能:

  • 跳过身份认证;
  • 放宽租户隔离;
  • 使用没有权限的缓存;
  • 关闭输出安全校验;
  • 把内部错误详情返回给用户。

“低成本路径”仍然必须满足同样的权限和数据治理约束。尤其是缓存命中路径,不能因为没有调用模型就跳过授权检查。


七、一个可运行的本地示例:统计 TTFT、Token 和预算

下面示例不依赖真实模型服务,而是用可控的流模拟模型流式响应,便于验证计时、预算和取消逻辑。生产环境只需将 FakeProvider.stream 替换为具体 SDK 的适配器。

from dataclasses import dataclass
import time
from typing import Iterator


@dataclass
class Usage:
    input_tokens: int
    output_tokens: int
    cached_input_tokens: int = 0


@dataclass
class Chunk:
    text: str
    usage: Usage | None = None
    done: bool = False


class FakeProvider:
    def stream(self, prompt: str) -> Iterator[Chunk]:
        # 模拟供应商先等待,再逐片返回内容
        time.sleep(0.08)
        for text in ["可观测性", "需要", "同时记录", "延迟和成本。"]:
            time.sleep(0.02)
            yield Chunk(text=text)

        yield Chunk(
            text="",
            usage=Usage(
                input_tokens=len(prompt),
                output_tokens=4,
                cached_input_tokens=0,
            ),
            done=True,
        )


class Budget:
    def __init__(self, limit_usd: float):
        self.limit_usd = limit_usd
        self.reserved_usd = 0.0
        self.spent_usd = 0.0

    def reserve(self, amount: float) -> bool:
        if self.reserved_usd + self.spent_usd + amount > self.limit_usd:
            return False
        self.reserved_usd += amount
        return True

    def settle(self, reserved: float, actual: float) -> None:
        self.reserved_usd -= reserved
        self.spent_usd += actual


def calculate_cost(usage: Usage) -> float:
    # 价格仅用于本地演示,不代表任何供应商价格
    input_price = 2.0 / 1_000_000
    cached_price = 0.5 / 1_000_000
    output_price = 8.0 / 1_000_000

    normal_input = usage.input_tokens - usage.cached_input_tokens
    return (
        normal_input * input_price
        + usage.cached_input_tokens * cached_price
        + usage.output_tokens * output_price
    )


def call_with_observability(prompt: str, budget: Budget) -> str:
    estimated_cost = 0.001  # 生产环境应由 Token 估算器和 max_output_tokens 推导
    if not budget.reserve(estimated_cost):
        raise RuntimeError("budget_exceeded_before_call")

    provider = FakeProvider()
    started = time.monotonic()
    first_token_at = None
    output = []
    final_usage = None

    try:
        for chunk in provider.stream(prompt):
            if chunk.text:
                if first_token_at is None:
                    first_token_at = time.monotonic()
                    ttft_ms = (first_token_at - started) * 1000
                    print(f"TTFT: {ttft_ms:.1f} ms")

                output.append(chunk.text)
                print(chunk.text, end="", flush=True)

            if chunk.done:
                final_usage = chunk.usage

        if final_usage is None:
            raise RuntimeError("provider_finished_without_usage")

        actual_cost = calculate_cost(final_usage)
        budget.settle(estimated_cost, actual_cost)

        print()
        print(f"usage={final_usage}")
        print(f"actual_cost=${actual_cost:.8f}")
        return "".join(output)

    except Exception:
        # 真实系统应根据供应商返回的部分 usage 进行结算;
        # 这里为了演示,保留预留金额,避免把失败请求误记为零成本。
        raise


if __name__ == "__main__":
    budget = Budget(limit_usd=0.01)
    answer = call_with_observability(
        prompt="解释 AI 可观测性与成本治理",
        budget=budget,
    )
    print(f"\nanswer={answer}")
    print(f"spent=${budget.spent_usd:.8f}")

运行时会看到类似输出:

TTFT: 80.x ms
可观测性需要同时记录延迟和成本。
usage=Usage(input_tokens=11, output_tokens=4, cached_input_tokens=0)
actual_cost=$0.00005400

这个例子有三个重要边界:

  1. time.sleep 只是模拟网络和生成过程,不能用于推断真实模型性能;
  2. len(prompt) 不是 Token 计数,只是为了让示例可运行;
  3. 生产 SDK 可能在流结束事件、独立 usage 事件或最终响应对象中返回 usage,必须以具体 API 文档为准。

生产适配器至少应统一以下异常:

AuthenticationError
InvalidRequestError
RateLimitError
TimeoutError
ProviderUnavailableError
ContentPolicyError
ClientCancelled

统一后,重试器和降级器才能基于错误类别工作,而不是通过匹配字符串判断。


八、可观测性数据应该如何关联

一次模型 Span 建议包含以下字段:

{
  "trace_id": "8f...",
  "span_id": "31...",
  "route": "answer_with_rag",
  "model": "provider-model-name",
  "model_version": "provider-version-if-available",
  "prompt_version": "prompt-42",
  "input_tokens": 12000,
  "cached_input_tokens": 3000,
  "output_tokens": 1500,
  "usage_completeness": "complete",
  "ttft_ms": 420,
  "total_latency_ms": 2380,
  "streaming": true,
  "retry_attempt": 1,
  "cache_result": "miss",
  "degradation_level": "normal",
  "status": "ok"
}

但不应默认记录完整 Prompt 和回答。它们可能包含个人信息、密钥、内部文档和受监管数据。可以采用以下方式:

  • 默认只记录 Prompt 哈希、长度、版本和 Token 数;
  • 对调试 Trace 使用显式采样;
  • 先做密钥、邮箱、手机号和身份标识脱敏;
  • 对完整内容实施加密、访问审计和短期保留;
  • 将业务日志与财务聚合日志分开保存。

Trace 还应关联模型、数据和评测版本:

model_version
prompt_version
retriever_version
embedding_model_version
dataset_version
policy_version
evaluation_run_id

这样才能回答:

成本上升是因为模型价格变化,还是 Prompt 变长?
TTFT 变慢是模型服务问题,还是检索返回了更多文档?
质量下降是模型切换,还是降级减少了上下文?
缓存命中率上升后,答案错误率是否也上升?

对于机器学习和深度学习训练任务,同样可以使用 Trace 思路,只是 Span 的粒度不同:

training_run
├── dataset_load
├── preprocessing
├── train_epoch
├── validation
├── checkpoint_save
├── evaluation
└── model_registry_publish

训练成本除了 GPU 时间,还包括数据处理、存储、网络和失败重跑。训练任务应记录:

dataset hash
code commit
hyperparameters
GPU type and count
training duration
checkpoint
validation metrics
artifact location

这使得线上模型问题能够追溯到训练数据、代码提交和评测结果,而不是把生成式 AI 与传统机器学习治理割裂开。


九、从指标判断故障,而不是只看平均数

1. 成本指标

成本应至少按以下层次聚合:

请求级
Trace 级
用户 / 租户级
路由级
模型级
区域级
日 / 周 / 月周期

常用指标包括:

cost_usd_total
cost_usd_per_request
input_tokens_total
output_tokens_total
retry_cost_usd
cache_savings_usd
degradation_count
budget_rejection_count

“平均每请求成本”可能被少数长上下文请求掩盖,因此还要观察 p50、p95 和最大值。

2. 延迟诊断矩阵

现象 可能原因 优先检查
TTFT 高、生成速度正常 输入过长、供应商排队、网络上传 输入 Token、队列等待、模型侧时间
TTFT 正常、总延迟高 输出过长、生成速度下降、下游发送慢 输出 Token、Token/s、客户端写入
总延迟正常、用户很晚看到内容 缓冲、代理未刷新、客户端读取不及时 SSE/HTTP 缓冲、首片段转发
成本升高、Token 未明显增加 重试、推理 Token、模型价格变化 attempt Span、usage 明细、价格表版本
缓存命中率升高、质量下降 缓存键过宽或过期 数据版本、权限边界、语义阈值
错误率不高、预算快速耗尽 单次请求很贵或隐式重试 Trace 成本分布、工具循环、输出上限

3. 质量不能被成本指标替代

成本下降不等于系统变好。降级、截断上下文和缓存复用都可能降低答案质量。应把成本治理与评测关联:

cost_per_successful_task
cost_per_accepted_answer
quality_score / dollar
groundedness / dollar

如果 Judge 评测、规则校验或人工反馈表明某种降级使任务成功率从 95% 降到 70%,即使模型成本下降 30%,单位成功任务成本也可能上升。

例如:

完整路径:每次 $0.10,成功率 95%
降级路径:每次 $0.07,成功率 70%

每个成功任务的期望成本为:

Csuccess=CrequestP(success)C_{\text{success}} = \frac{C_{\text{request}}}{P(\text{success})}

完整路径:

0.100.950.1053\frac{0.10}{0.95} \approx 0.1053

降级路径:

0.070.70=0.10\frac{0.07}{0.70} = 0.10

此时降级路径略便宜,但如果成功率进一步降到 60%,则:

0.070.600.1167\frac{0.07}{0.60} \approx 0.1167

成本治理必须使用“每个成功任务的成本”,而不是只看单次调用价格。


十、生产边界和常见误解

误解一:HTTP 200 就表示请求成功

模型可能返回 HTTP 200,但输出为空、格式非法、引用不存在或违反业务约束。状态应至少分为:

transport_status
provider_status
application_status
quality_status

例如模型调用成功但 JSON 校验失败,不能把整次 Trace 标记为完全成功。

误解二:客户端取消意味着没有成本

取消只表示客户端不再等待或连接被关闭,不代表供应商已经停止计算。系统应向供应商发送取消信号,并记录:

cancel_requested_at
provider_cancel_ack_at
tokens_before_cancel
cancel_effective

如果供应商没有确认取消,就按可能产生费用处理。

误解三:设置 max output tokens 就能限制总成本

输出上限只能限制输出部分。输入历史、工具定义、检索文档、重试次数和内部推理 Token 仍可能产生成本。总预算必须同时约束:

max_input_tokens
max_output_tokens
max_tool_rounds
max_retries
deadline
estimated_cost

误解四:缓存命中率越高越好

缓存命中率是手段,不是目标。缓存应与新鲜度、权限和质量一起评估。一个 99% 命中但返回过期数据的系统,并不比低命中率系统更可靠。

误解五:只采样错误 Trace 就足够

错误 Trace 对排障重要,但只采样错误会丢失正常基线。至少应保留:

  • 固定比例的正常 Trace;
  • 所有预算拒绝和降级 Trace;
  • 所有供应商错误;
  • 延迟异常 Trace;
  • 评测和回归关联 Trace。

采样策略本身也应记录,否则某次采样规则变化可能被误认为错误率变化。


十一、落地时应形成闭环

一个完整的生产闭环如下:

请求进入
→ 创建 Trace
→ 认证与租户预算原子授权
→ 检查精确缓存
→ 执行检索、模型和工具调用
→ 每次尝试独立记录 usage、TTFT 和错误
→ 流式传输并处理取消
→ 结算实际成本
→ 更新缓存和评测样本
→ 聚合指标、触发告警和调整降级策略

其中最容易遗漏的是“执行中”的状态。只有请求前预算,没有执行中截止时间,会出现单次调用拖垮并发;只有结束后 Token 统计,没有每次尝试记录,会低估重试成本;只有总延迟,没有 TTFT,会无法解释用户为何长时间看不到任何输出;只有缓存命中率,没有版本和权限边界,会把错误答案稳定地复用下去。

最终,AI 生产系统应把模型、数据、评测、权限和成本放在同一条可追溯链路中:

trace_id
  → route / prompt / model
  → retrieval / tool / cache
  → tokens / TTFT / retries
  → budget / degradation
  → evaluation / user outcome

这样才能从“模型调用了多少次”进一步回答“为什么调用、花了多少、是否值得、是否安全,以及下一次应如何改变系统”。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。