《互联网产品的 AI 架构·模型接入与能力边界》12.1 一次 AI 请求的最小观测字段

从「回声」一次查不到模型的线上投诉出发,说明一次 AI 请求必须记录哪些字段、耗时如何拆段、用量与错误类别如何统一,以及内容脱敏与分层采样如何让观测既够用又不越界;文中给出可逐条断言的 TypeScript 字段契约、脱敏规则、采样分桶与真实测试输出,帮助团队把一次请求追到模型、租户与错误类别。

一次查不到模型的投诉

「回声」的客服接到一条投诉:用户说 AI 推荐的商品和对话内容完全对不上。值班同学打开日志,只看到一条 POST /v1/chat 200 和总耗时 1.8 秒。没有模型名,没有能力标识,没有请求 id,也没有这次请求属于哪个租户。他只能猜。

这类问题不是“日志打得少”,而是“打的字段回答不了问题”。日志里有时间、有状态码、有耗时,却没有任何一个字段能把这次请求和其他请求区分开。要查清它,至少需要知道:哪个业务能力、哪个租户、路由到了哪个模型、时间花在哪一段、用了多少 token、结果是成功还是降级。

本节把这些信息收敛成一份最小字段契约,并说明三个容易做错的地方:耗时不能只记总数,错误类别不能直接透传提供方,用户原文不能进日志。

文中出现的比例、耗时与字段数量,除非标注为本机测试输出,都是案例假设值。

最小字段集:回答五个问题

字段不是越多越好。每多一个字段,就多一份存储、一份脱敏责任和一份跨版本兼容成本。最小的集合应该刚好回答五个问题。

这是哪一次请求

traceId 与 requestId 是两个不同层级的东西。traceId 串起一次用户动作经过的所有服务,requestId 标识网关这一层的唯一调用。很多团队只留一个,结果要么跨服务串不起来,要么重试时无法区分“同一次请求的第二次尝试”。

tenantId 不能省。没有租户,账单、配额、投诉和灰度都无法归因。它也是排查“是不是某个客户在刷”的唯一入口。

用的是哪个模型、哪个能力

capability 是业务视角,例如 chat.reply;modelId 与 providerId 是执行视角。只记模型不记能力,无法回答“同一个能力换模型后质量有没有变化”;只记能力不记模型,无法回答“这次到底是谁产生的”。

region 看似可选,但跨境路由、数据驻留和延迟归因都依赖它。字段可以为空字符串吗?不可以。空字符串会让“没采集”和“采集到了空值”混在一起,契约里应当要求非空。

时间花在哪一段

总耗时 1.8 秒可能意味着模型慢、排队久、后处理重,也可能只是网络抖动。四种原因对应四种完全不同的处理方式。因此耗时至少拆成四段:排队、路由、提供方、后处理,再加一个端到端总数。

拆分之后还要有一条约束:总耗时不得小于各分段之和。这条约束能抓到两类错误:分段漏记,以及总耗时用了另一个时钟。它不能证明数字一定正确,但能拦住明显自相矛盾的记录。

用量、结果与错误类别

usage 记录输入 token、输出 token 与成本微元。成本用整数微元,原因在 12.2 展开。这里只需要强调:失败请求也要有 usage。模型已经处理了一部分输入才超时,这部分成本真实发生,不能因为结果是失败就记零。

outcome 只有三个值:ok、degraded、error。降级不是错误,它是产品仍然给出了可用结果,只是没走主路径;错误则是没有给出结果。把降级混进错误,会让错误率虚高;把错误混进降级,会让可用性虚高。

errorKind 使用网关自己的错误类别,而不是提供方的原始错误码。原因很实际:提供方有几十个错误码,业务方只关心“能不能重试、是不是额度问题、是不是内容被拦”。统一类别让看板和告警有稳定的维度。

约束是双向的:ok 时 errorKind 与 degradeReason 必须为空;degraded 时必须有降级原因;error 时必须有错误类别。三条规则让结果字段不可能自相矛盾。

脱敏:原文不进观测链路

最容易犯的错,是把用户输入和模型输出直接写进日志。它们可能包含手机号、地址、订单号、身份证号,也可能包含用户对第三方的投诉。观测系统的访问权限通常比业务数据库宽,一旦写进去,很难收回。

做法不是“打码几个字段”,而是从结构上不允许原文进入:需要关联内容时,只记录长度与摘要。长度用于发现异常输入,摘要用于把同一次输入的多条日志串起来。两者都不泄露内容。

metadata 采用白名单。业务方可能塞进来 userId、email、prompt,白名单默认全部丢弃。即使键名合法,值里出现邮箱或长数字串也按敏感信息处理。白名单的维护成本比黑名单低,因为新字段默认是“不允许”,而不是“默认允许”。

分层采样

全量保留所有正常请求,成本会随流量线性上涨;只保留错误,又看不到正常请求的基线。可行的做法是分层:错误全留,降级按较高比例留,正常请求低频留。

采样必须确定性。按 traceId 做哈希分桶,同一个请求无论重放多少次都落在同一个桶里。随机采样会让“刚出问题就把样本抽没”成为可能,也会让同一个问题在两次查询里得到不同的样本。

分层采样还带来一个解释责任:看板上的绝对数不再是真实请求数,必须乘以采样率的倒数。如果团队只看原始计数,采样就把“观测”变成了“误导”。

实现

下面的实现包含字段契约、运行时校验、脱敏与采样四部分。它不依赖任何日志库,输出是一个普通对象,调用方可以写进文件、消息队列或追踪系统。

// file: src/observe/fields.ts
// tier: L1
// verify: node --test test/observe/fields.test.ts

import { createHash } from "node:crypto";
import type { AiErrorKind } from "../core/errors.ts";
import type { DegradeReason, Usage } from "../core/types.ts";

export const OBSERVATION_SCHEMA_VERSION = 1;

/** 耗时必须拆段记录,否则只能看到总数,无法定位是哪一段变慢。 */
export interface TimingBreakdown {
  queueMs: number;
  routeMs: number;
  providerMs: number;
  postprocessMs: number;
  /** 端到端总耗时,必须不小于各分段之和。 */
  totalMs: number;
}

export type ObservationOutcome = "ok" | "degraded" | "error";
/** 采样分层:错误全留,降级按比例留,正常请求低频留。 */
export type SampleTier = "error" | "degraded" | "normal";

export interface AiObservation {
  schemaVersion: number;
  traceId: string;
  requestId: string;
  tenantId: string;
  capability: string;
  providerId: string;
  modelId: string;
  region: string;
  timing: TimingBreakdown;
  usage: Usage;
  outcome: ObservationOutcome;
  errorKind: AiErrorKind | null;
  degradeReason: DegradeReason | null;
  sampleTier: SampleTier;
}

export interface ValidationIssue {
  path: string;
  message: string;
}

const TRACE_ID = /^[0-9a-f]{16,32}$/;
const NAME = /^[a-z][a-z0-9._:-]*$/;
const TEXT_FIELDS = ["requestId", "tenantId", "capability", "providerId", "modelId", "region"];
const TIMING_FIELDS = ["queueMs", "routeMs", "providerMs", "postprocessMs", "totalMs"];

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function checkName(value: unknown, path: string, issues: ValidationIssue[]): void {
  if (typeof value !== "string" || value.trim() === "") {
    issues.push({ path, message: "must be a non-empty string" });
  } else if (!NAME.test(value)) {
    issues.push({ path, message: "must match /^[a-z][a-z0-9._:-]*$/" });
  }
}

function checkCount(value: unknown, path: string, issues: ValidationIssue[]): void {
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
    issues.push({ path, message: "must be a non-negative integer" });
  }
}

/**
 * 观测字段的运行时校验。类型系统在边界处不可信:
 * 日志可能来自旧版本客户端,也可能被手工拼接。
 */
export function validateObservation(value: unknown): ValidationIssue[] {
  if (!isRecord(value)) return [{ path: "$", message: "observation must be an object" }];
  const issues: ValidationIssue[] = [];

  if (value.schemaVersion !== OBSERVATION_SCHEMA_VERSION) {
    issues.push({ path: "schemaVersion", message: `must equal ${OBSERVATION_SCHEMA_VERSION}` });
  }
  if (typeof value.traceId !== "string" || !TRACE_ID.test(value.traceId)) {
    issues.push({ path: "traceId", message: "must be 16-32 lowercase hex characters" });
  }
  for (const field of TEXT_FIELDS) checkName(value[field], field, issues);

  if (!isRecord(value.timing)) {
    issues.push({ path: "timing", message: "must be an object" });
  } else {
    for (const field of TIMING_FIELDS) checkCount(value.timing[field], `timing.${field}`, issues);
    const sum = TIMING_FIELDS.slice(0, 4).reduce((total, field) => {
      const segment = value.timing[field];
      return total + (typeof segment === "number" ? segment : 0);
    }, 0);
    const total = value.timing.totalMs;
    if (typeof total === "number" && total < sum) {
      issues.push({ path: "timing.totalMs", message: `must be >= sum of segments (${sum})` });
    }
  }

  if (!isRecord(value.usage)) {
    issues.push({ path: "usage", message: "must be an object" });
  } else {
    for (const field of ["inputTokens", "outputTokens", "costMicros"]) {
      checkCount(value.usage[field], `usage.${field}`, issues);
    }
  }

  if (value.outcome !== "ok" && value.outcome !== "degraded" && value.outcome !== "error") {
    issues.push({ path: "outcome", message: "must be ok, degraded or error" });
  }
  if (value.errorKind !== null && typeof value.errorKind !== "string") {
    issues.push({ path: "errorKind", message: "must be a string or null" });
  }
  if (value.degradeReason !== null && typeof value.degradeReason !== "string") {
    issues.push({ path: "degradeReason", message: "must be a string or null" });
  }
  if (value.outcome === "ok") {
    if (value.errorKind !== null) issues.push({ path: "errorKind", message: "must be null when outcome is ok" });
    if (value.degradeReason !== null) issues.push({ path: "degradeReason", message: "must be null when outcome is ok" });
  }
  if (value.outcome === "degraded" && value.degradeReason === null) {
    issues.push({ path: "degradeReason", message: "is required when outcome is degraded" });
  }
  if (value.outcome === "error" && value.errorKind === null) {
    issues.push({ path: "errorKind", message: "is required when outcome is error" });
  }
  if (value.sampleTier !== "error" && value.sampleTier !== "degraded" && value.sampleTier !== "normal") {
    issues.push({ path: "sampleTier", message: "must be error, degraded or normal" });
  }
  return issues;
}

export interface RedactedText {
  /** 原文字符数,用于发现异常长的输入,但不暴露内容。 */
  chars: number;
  /** 稳定摘要,便于把同一次输入的多条日志关联起来。 */
  sha256: string;
}

/** 提示词与用户文本只保留长度与摘要,原文一律不进入观测链路。 */
export function redactText(text: string): RedactedText {
  return {
    chars: Array.from(text).length,
    sha256: createHash("sha256").update(text, "utf8").digest("hex"),
  };
}

const METADATA_ALLOWLIST = new Set(["surface", "locale", "clientVersion", "experiment", "route"]);
const SENSITIVE_VALUE = /(@|\+?\d[\d\s-]{6,}\d)/;

/**
 * metadata 采用白名单而不是黑名单:业务方随手塞进来的字段默认全部丢弃。
 * 即使键名合法,值里出现邮箱或长数字串也按敏感信息处理。
 */
export function redactMetadata(metadata: Record<string, string>): Record<string, string> {
  const safe: Record<string, string> = {};
  for (const [key, value] of Object.entries(metadata)) {
    if (!METADATA_ALLOWLIST.has(key)) continue;
    if (SENSITIVE_VALUE.test(value)) continue;
    safe[key] = Array.from(value).slice(0, 64).join("");
  }
  return safe;
}

export interface ObservationLogInput {
  observation: AiObservation;
  /** 原始内容仅用于生成摘要,函数返回前不会保留原文。 */
  content?: string[];
  metadata?: Record<string, string>;
}

export interface ObservationLogRecord extends AiObservation {
  contentDigests: RedactedText[];
  metadata: Record<string, string>;
}

/** 把一次请求收敛成可落盘的记录:原文被摘要替换,metadata 被白名单过滤。 */
export function toLogRecord(input: ObservationLogInput): ObservationLogRecord {
  return {
    ...input.observation,
    contentDigests: (input.content ?? []).map(redactText),
    metadata: redactMetadata(input.metadata ?? {}),
  };
}

export interface SamplingPolicy {
  /** 正常请求的保留比例,0 到 1。 */
  normalRate: number;
  /** 降级请求的保留比例。 */
  degradedRate: number;
  /** 错误是否无条件保留。 */
  keepAllErrors: boolean;
}

export const DEFAULT_SAMPLING: SamplingPolicy = {
  normalRate: 0.02,
  degradedRate: 0.5,
  keepAllErrors: true,
};

function fnv1a(text: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash;
}

/** 按 traceId 做确定性分桶,同一个请求重放多少次都落在同一个桶。 */
export function sampleBucket(traceId: string): number {
  return fnv1a(traceId) % 10_000;
}

export interface SampleDecision {
  tier: SampleTier;
  bucket: number;
  emit: boolean;
}

function withinRate(bucket: number, rate: number): boolean {
  if (rate <= 0) return false;
  if (rate >= 1) return true;
  return bucket < Math.round(rate * 10_000);
}

/**
 * 分层采样:错误全量保留,降级按较高比例保留,正常请求低频保留。
 * 分桶只看 traceId,避免随机抖动把刚出问题的样本抽没。
 */
export function decideSample(
  observation: AiObservation,
  policy: SamplingPolicy = DEFAULT_SAMPLING,
): SampleDecision {
  const tier: SampleTier =
    observation.outcome === "error" ? "error" : observation.outcome === "degraded" ? "degraded" : "normal";
  const bucket = sampleBucket(observation.traceId);
  if (tier === "error") return { tier, bucket, emit: policy.keepAllErrors };
  const rate = tier === "degraded" ? policy.degradedRate : policy.normalRate;
  return { tier, bucket, emit: withinRate(bucket, rate) };
}

校验函数返回问题列表而不是抛异常。原因是观测数据经常来自旧版本或第三方,一条坏记录不应该让整个批次失败;调用方可以选择丢弃、降级或打标。真正需要强一致的地方是计费,那里才用抛错(12.2)。

脱敏用 SHA-256 而不是自己写的哈希。自己写的哈希很容易在“够不够散”上出错,而观测摘要的碰撞会直接把两条不同输入混成一条。Node 内置的 node:crypto 已经足够,不需要第三方依赖。

采样分桶用 FNV-1a,因为它便宜、稳定、无需加密强度。采样只需要均匀,不需要抗碰撞。

测试与真实输出

测试覆盖字段校验、耗时约束、结果一致性、文本脱敏、metadata 白名单、日志记录、分桶稳定性与采样比例。执行:

node --test test/observe/fields.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 11
ℹ pass 11
ℹ fail 0
ℹ duration_ms 143.776333

采样比例的测试没有断言“大约 2%”,而是直接把比例设成 0 和 1,验证边界;再用当前桶值构造刚好高于和低于阈值的比例,验证分层确实走了各自的阈值。比例类逻辑最容易写出“看起来对、边界错”的实现。

字段契约的落地顺序

先把字段写进一次真实请求,再考虑接哪个观测平台。顺序反了,团队会花大量时间适配平台,却仍然回答不了“这次用的是哪个模型”。

第二步是让字段成为发布门禁:新增能力必须填 capability,新增提供方必须填 providerId,任何降级必须填 degradeReason。没有这些字段的请求可以继续跑,但要在看板上单列,直到补齐。

第三步才是采样与保留策略。字段不全时谈采样,只会把不完整的数据采得更少。

小结

一次 AI 请求的最小观测字段要回答五件事:哪一次请求、哪个能力与模型、时间花在哪、用了多少、结果如何。耗时必须拆段并满足总和约束;错误类别要收敛成网关自己的枚举;原文只留长度与摘要;采样按结果分层并保持确定性。

字段契约的价值不在于“记得多”,而在于每条记录都能被追问到底。下一节把其中的用量字段接到账本上,讨论计量、幂等与日结对账。

阅读导航:上一节:11.3 输出审查与内容安全接入 · 下一节:12.2 计量、账单与配额对账 。

常见错误与边界

  • 只记总耗时。没有分段,慢在哪一段只能靠猜。
  • 只记模型不记能力。换模型后的质量变化无法按业务口径归因。
  • 把提供方错误码直接透传。业务方需要的是可重试性,不是厂商内部编号。
  • 失败请求不记 usage。已经消耗的 token 不会因为失败而退回。
  • 把降级算作错误。降级仍是可用结果,混在一起会让错误率失真。
  • 把用户原文写进日志。观测系统的访问面通常比业务库更宽。
  • 用随机采样。重放同一个请求会得到不同样本,问题无法稳定复现。
  • 看板直接展示采样后的绝对数。没有乘以采样率倒数,数字就是错的。
  • 字段一次定死。提供方、能力与合规要求都会变,契约需要版本号。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查