一次查不到模型的投诉
「回声」的客服接到一条投诉:用户说 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 不会因为失败而退回。
- 把降级算作错误。降级仍是可用结果,混在一起会让错误率失真。
- 把用户原文写进日志。观测系统的访问面通常比业务库更宽。
- 用随机采样。重放同一个请求会得到不同样本,问题无法稳定复现。
- 看板直接展示采样后的绝对数。没有乘以采样率倒数,数字就是错的。
- 字段一次定死。提供方、能力与合规要求都会变,契约需要版本号。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。