LLM Trace 脱敏工程实践:日志能排障,但别把用户隐私也存下来

0 阅读14分钟

凌晨两点,线上客服机器人开始批量答非所问。你打开可观测性平台,看到了完整的请求链路:用户问题、检索到的知识库片段、工具调用参数、模型输出、token、耗时、错误栈,一切都很清楚。

然后你突然发现,trace 里还有用户手机号、合同编号、内部工单备注、某个工具调用的临时 token,甚至有一段被 RAG 捞出来的客户私有文档。

这就是 LLM 应用和传统 Web 服务最大的区别:传统日志通常记录“谁在什么时候调了哪个接口”;LLM trace 记录的是“模型当时看见了什么、想了什么、调用了什么、回答了什么”。它不是普通日志,而是一次推理现场的录像。

如果你只关心排障,最简单的做法当然是全量上报:prompt 原文、completion 原文、retrieval context 原文、tool arguments 原文,统统扔进观测平台。问题是,一旦这样做,trace 平台就变成了第二套影子数据库,而且这套数据库通常没有业务库那样严格的字段权限、脱敏策略、生命周期和审计流程。

这篇文章讨论一个很工程化的问题:怎样设计 LLM trace,让它既能排障、回放、做 eval,又不要把用户隐私和企业知识资产长期裸奔在日志里。

1. 为什么 LLM Trace 比普通日志更危险

普通 API 日志里,最敏感的通常是 header、cookie、用户 ID、请求体里的少数字段。成熟团队会有一套固定处理:access log 不打 body,错误日志屏蔽 token,数据库慢查询只存 SQL 模板。

LLM 应用不一样。一次调用里可能出现这些内容:

  • 用户输入:自然语言里很容易夹带手机号、邮箱、地址、身份证、订单号、合同号。
  • 系统上下文:system prompt、业务策略、风控规则、内部 SOP。
  • RAG 检索结果:企业知识库、客户材料、售后记录、文档片段。
  • 工具调用参数:用户 ID、订单 ID、支付单号、第三方 API 参数。
  • 模型输出:可能复述了敏感信息,也可能把工具结果拼进回答。
  • 元数据:tenant、环境、功能开关、模型、成本、trace id、session id。

做 Agent 后,风险还会放大。Agent 不是一次请求就结束,它会在循环里不断累积消息历史、工具结果、中间计划和失败原因。Context engineering 的核心是管理“模型下一次推理能看到什么”,而 trace 往往会把这些上下文都记录下来。也就是说,trace 数据的敏感面约等于模型上下文的敏感面。

这也是为什么“上线后再补脱敏”通常来不及。只要第一批生产 trace 已经裸奔写入平台,你就需要面对存量数据清理、备份清理、权限追溯和人员访问记录。工程上,最便宜的做法永远是在数据离开业务进程前处理掉。

2. 先给字段分级,不要一上来写正则

很多团队做脱敏的第一反应是写一堆 regex:手机号替换成 ***,邮箱替换成 ***@***,API key 替换成 [REDACTED]。这有用,但不够。

原因很简单:LLM trace 里的敏感性不只来自字符串形态,还来自字段语义。同样是 id=12345,如果它是本地测试 case id,问题不大;如果它是租户 ID、订单 ID、支付流水 ID,就可能需要保护。同样是一段普通中文,如果来自公开帮助文档,风险低;如果来自客户上传合同,风险高。

我更推荐先做字段分级,再做规则。

等级例子默认动作可否进入长期 trace
P0 凭证类API key、cookie、临时 token、授权码删除或固定占位不允许
P1 个人/客户敏感信息手机号、邮箱、地址、证件号、真实姓名、订单号脱敏或哈希默认不允许原文
P2 业务敏感上下文内部 SOP、合同片段、私有知识库、工具返回详情摘要化、截断、按租户策略处理受控允许
P3 排障元数据耗时、模型、token、错误码、路由节点、版本号保留允许

注意这里有一个关键点:P3 才是可观测性最稳定、最应该保留的核心。很多排障并不需要完整用户原文,只需要知道“哪个版本、哪个模型、哪个 prompt 版本、检索了几个 chunk、topK 多少、工具失败在哪一步、错误码是什么”。

真正需要原文的场景,应该进入更严格的 debug 通道,而不是默认长期留在所有 trace 里。

3. 三个脱敏时机:入口、Span 构建、Export 前

LLM trace 脱敏不要只放一个位置。比较稳的架构是三层:

  1. 入口层:业务请求刚进入 LLM pipeline 时,识别明显凭证和 PII,生成 safe_input
  2. Span 构建层:每个步骤写 trace 时,只允许写入 schema 白名单字段。
  3. Export 前:在 trace SDK 或 OpenTelemetry exporter 发出前,做最后一次兜底 patch。

为什么要三层?

入口层适合处理业务语义,比如“这个 tenant 是金融客户,默认不保留原文”。Span 构建层适合强约束开发者行为,避免有人随手 span.setAttribute("prompt", JSON.stringify(req))。Export 前适合兜底第三方 SDK 自动采集的字段,比如某些 LLM SDK 会把 prompt 或 completion 按标准属性写进 span。

一些观测工具已经提供了类似能力:有的支持在 SDK 里配置 masking hook,在 trace 数据发出前修改 OpenTelemetry span attributes;有的支持隐藏 inputs/outputs、按 regex/anonymizer 脱敏、按请求条件关闭 tracing。这说明行业方向很明确:脱敏应该尽量发生在数据离开应用之前。

4. 一个可落地的 TypeScript Redactor

下面是一套简化实现。它不依赖具体观测平台,目标是把脱敏逻辑放在业务进程内,先产出安全 payload,再交给 trace SDK。

type Sensitivity = "P0" | "P1" | "P2" | "P3";

type RedactPolicy = {
  tenantId: string;
  keepRawInput: boolean;
  hashSalt: string;
  maxTextLength: number;
};

type RedactResult<T> = {
  safe: T;
  findings: Array<{
    path: string;
    level: Sensitivity;
    rule: string;
  }>;
};

const RULES: Array<{
  name: string;
  level: Sensitivity;
  pattern: RegExp;
  replace: (v: string) => string;
}> = [
  {
    name: "api_key",
    level: "P0",
    pattern: /\b(sk|ak|tk)-[a-zA-Z0-9_-]{16,}\b/g,
    replace: () => "[REDACTED_SECRET]",
  },
  {
    name: "phone_cn",
    level: "P1",
    pattern: /\b1[3-9]\d{9}\b/g,
    replace: (v) => `${v.slice(0, 3)}****${v.slice(-4)}`,
  },
  {
    name: "email",
    level: "P1",
    pattern: /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi,
    replace: (v) => {
      const [name, domain] = v.split("@");
      return `${name.slice(0, 2)}***@${domain}`;
    },
  },
  {
    name: "id_card_like",
    level: "P1",
    pattern: /\b\d{17}[\dXx]\b/g,
    replace: (v) => `${v.slice(0, 4)}**********${v.slice(-4)}`,
  },
];

function stableHash(input: string, salt: string): string {
  // 示例:生产里请换成 crypto.createHmac('sha256', salt)
  let h = 2166136261;
  const s = `${salt}:${input}`;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
  }
  return `hash_${(h >>> 0).toString(16)}`;
}

function redactText(text: string, path: string, policy: RedactPolicy) {
  let output = text;
  const findings: RedactResult<string>["findings"] = [];

  for (const rule of RULES) {
    output = output.replace(rule.pattern, (matched) => {
      findings.push({ path, level: rule.level, rule: rule.name });
      return rule.replace(matched);
    });
  }

  if (!policy.keepRawInput && output.length > policy.maxTextLength) {
    findings.push({ path, level: "P2", rule: "truncate_long_text" });
    output = `${output.slice(0, policy.maxTextLength)}...[TRUNCATED:${output.length}]`;
  }

  return { output, findings };
}

function redactObject(value: unknown, policy: RedactPolicy, path = "$": RedactResult<any> {
  const findings: RedactResult<any>["findings"] = [];

  if (typeof value === "string") {
    const r = redactText(value, path, policy);
    return { safe: r.output, findings: r.findings };
  }

  if (Array.isArray(value)) {
    const arr = value.map((item, i) => {
      const r = redactObject(item, policy, `${path}[${i}]`);
      findings.push(...r.findings);
      return r.safe;
    });
    return { safe: arr, findings };
  }

  if (value && typeof value === "object") {
    const input = value as Record<string, unknown>;
    const out: Record<string, unknown> = {};

    for (const [key, item] of Object.entries(input)) {
      const lower = key.toLowerCase();

      // 字段语义优先:凭证类字段直接删除,不等 regex 命中
      if (["authorization", "cookie", "token", "secret", "api_key"].some(k => lower.includes(k))) {
        out[key] = "[REDACTED_SECRET]";
        findings.push({ path: `${path}.${key}`, level: "P0", rule: "secret_field_name" });
        continue;
      }

      // 高基数字段做稳定哈希,保留排障关联能力,不保留原文
      if (["user_id", "userid", "order_id", "session_id"].includes(lower)) {
        out[key] = stableHash(String(item), policy.hashSalt);
        findings.push({ path: `${path}.${key}`, level: "P1", rule: "stable_hash_identifier" });
        continue;
      }

      const r = redactObject(item, policy, `${path}.${key}`);
      out[key] = r.safe;
      findings.push(...r.findings);
    }

    return { safe: out, findings };
  }

  return { safe: value, findings };
}

这段代码有几个故意的取舍。

第一,字段名规则优先于正则。因为凭证可能不是标准格式,token: "abc" 也不应该进入 trace。

第二,用户 ID、订单 ID 这类字段不直接删除,而是稳定哈希。这样排障时仍然能回答:“同一个用户是否连续失败”“同一个订单是否反复触发工具异常”,但看不到原始 ID。

第三,脱敏结果保留 findings。这很重要。你需要知道本次 trace 命中了哪些规则,否则很难评估脱敏策略是否过严或过松。

5. Trace Wrapper:默认只写 Safe Payload

有了 redactor,还要让开发者“不容易写错”。下面是一个极简 wrapper:业务代码只调用 traceLLMStep,而不是直接操作底层 span。

type TraceStepInput = {
  traceId: string;
  name: string;
  tenantId: string;
  input?: unknown;
  output?: unknown;
  metadata?: Record<string, unknown>;
  error?: Error;
};

type TraceSink = {
  span: (name: string, attrs: Record<string, unknown>) => void;
};

function traceLLMStep(sink: TraceSink, step: TraceStepInput, policy: RedactPolicy) {
  const safeInput = step.input === undefined
    ? undefined
    : redactObject(step.input, policy, "$.input");

  const safeOutput = step.output === undefined
    ? undefined
    : redactObject(step.output, policy, "$.output");

  const safeExtra = redactObject(step.metadata ?? {}, policy, "$.metadata");

  const findings = [
    ...(safeInput?.findings ?? []),
    ...(safeOutput?.findings ?? []),
    ...safeExtra.findings,
  ];

  sink.span(step.name, {
    "trace.id": step.traceId,
    "tenant.hash": stableHash(step.tenantId, policy.hashSalt),
    "llm.input.safe": safeInput?.safe,
    "llm.output.safe": safeOutput?.safe,
    "llm.metadata.safe": safeExtra.safe,
    "redaction.applied": findings.length > 0,
    "redaction.finding_count": findings.length,
    "redaction.rules": [...new Set(findings.map(f => f.rule))].join(","),
    "error.type": step.error?.name,
    "error.message.safe": step.error ? redactText(step.error.message, "$.error.message", policy).output : undefined,
  });
}

这层 wrapper 解决的不是“脱敏算法有多聪明”,而是“团队默认路径是否安全”。如果默认路径就是安全 payload,开发者想写原文反而需要显式走例外流程,事故概率会低很多。

我的经验是,脱敏工程最怕靠约定,比如“大家记得不要把 prompt 打进去”。这种约定只要新人接手、线上救火、临时加 debug,一定会被破坏。应该让 SDK 形态逼着大家走安全路径。

6. 排障需要原文怎么办?设计 Debug Escrow

到这里会有人反驳:不存原文,线上疑难问题怎么复现?尤其是 RAG 和 Agent,少一个 chunk、少一句用户输入,模型行为就变了。

这是现实问题,所以方案不能极端到“永远不保留任何原文”。更可行的是把原文从默认 trace 通道拆出来,进入 Debug Escrow:一个更短保留期、更强权限、更完整审计的临时调试区。

可以这样设计:

  • 默认 trace:只保留脱敏后的输入输出、结构化元数据、错误码、版本信息。
  • Debug Escrow:只在特定 tenant、特定 trace、特定时间窗口打开。
  • 开启方式:需要工单 ID、负责人、原因、过期时间。
  • 保留期:例如 24 小时或 72 小时,到期自动清理。
  • 访问记录:谁查看了原文、查看了哪条 trace、何时查看,都写审计日志。
  • 导出限制:禁止批量导出,或导出时强制二次脱敏。

这样做的好处是,你没有牺牲疑难问题排障能力,但把“看原文”从默认行为变成受控例外。

这和传统生产数据库的 break-glass access 很像:不是没人能查,而是不能随便查、不能长期查、不能查完没记录。

7. RAG Trace:不要把所有 Chunk 都当日志存

RAG 场景尤其容易踩坑。很多实现会把 retrieval context 原样写进 trace:query、topK、每个 chunk 的全文、score、source URL。排障时确实方便,但风险也最大。

更稳的做法是把 chunk 拆成三层:

字段是否默认保留用途
document_id_hash关联来源文档
chunk_id_hash定位检索单元
score / rank分析召回质量
chunk_summary视策略快速理解上下文
chunk_text_raw否,进 Debug Escrow精确复现

如果你要把生产 trace 回放成 eval dataset,这个设计也更稳。deepeval 这类评测框架通常需要 test case、metric、dataset;RAG 测试 case 可能包含 input、actual output、retrieval context。问题是,retrieval context 直接进 golden set,就等于把客户数据复制到第三个地方。

我建议 eval 数据入库前再做一次“数据集级别脱敏”:

  1. trace 中的 chunk_id_hash 可以进入 dataset;
  2. 原始 chunk 文本只允许从知识库按权限重新拉取;
  3. golden set 中保留最小必要上下文,优先用摘要而不是全文;
  4. 如果必须保留全文,标记数据来源、租户、授权范围和过期时间。

这会让 eval pipeline 麻烦一点,但可以避免“为了改进模型,把生产隐私样本永久沉淀成训练/评测资产”。

8. Tool Calling:参数比回答更敏感

很多团队会重点盯 prompt 和 completion,却忽略 tool arguments。实际上,工具调用参数经常更敏感。

例如:

{
  "tool": "refund_order",
  "arguments": {
    "order_id": "2026080312345678",
    "user_phone": "13812345678",
    "reason": "客户说孩子误点了购买按钮",
    "operator_token": "tk-live-xxxxxx"
  }
}

这段 trace 对排障很有价值:我们能看到 Agent 为什么触发退款、传了哪个订单、工具是否成功。但它也包含手机号、订单号、业务备注,甚至错误地带上了 token。

工具调用 trace 推荐拆成:

  • tool name:保留;
  • arguments schema version:保留;
  • id 类字段:哈希;
  • PII:脱敏;
  • token/secret:删除;
  • tool result raw:默认不保留;
  • tool result summary:保留;
  • permission decision:保留,比如 allowed_by=refund_policy_v3

这比只存“工具调用成功/失败”更有用,也比全量 arguments 更安全。

9. 采样策略:不是所有 Trace 都值得同等保留

脱敏之外,还要做采样。否则高 QPS 业务会被 trace 成本和数据治理成本拖垮。

一个实用策略:

  • 错误请求:高采样率,但仍然脱敏;
  • 高延迟请求:高采样率,保留阶段耗时;
  • 普通成功请求:低采样率,只保留摘要指标;
  • 命中敏感规则过多的请求:降低原文保留概率,或强制只保留结构化元数据;
  • 新版本灰度期:临时提高采样率,到期自动恢复。

采样决策也应该写进 trace:sampling.reason=errorsampling.rate=1.0redaction.level=strict。否则后面做分析时,你会误以为观测数据代表了全量流量。

10. 保留期和权限:Trace 平台不是永久仓库

很多事故不是采集当天发生的,而是半年后发生的:某个账号权限过大、某个导出文件流转到了不该去的地方、某个备份没清理。

所以 LLM trace 至少要有三档保留期:

数据类型建议保留说明
聚合指标6-18 个月成本、延迟、错误率趋势
脱敏 trace14-90 天排障与回归分析
原文 debug payload24-72 小时严格审批,自动删除

权限也要按字段分层。不是所有能看 trace 的人都应该看 prompt;不是所有能看 prompt 的人都应该看工具结果;不是所有能看工具结果的人都应该导出数据。

如果你的观测平台暂时做不到字段级权限,那至少要在写入前把字段拆开:长期平台只收 safe payload,原文进单独系统。不要把希望寄托在“大家不会点导出”。

11. 一张上线 Checklist

最后给一张我会在项目里直接用的 checklist。

采集边界

  • 是否明确哪些字段进入 trace,哪些字段永不进入?
  • 是否禁止默认记录完整 prompt / completion / retrieval context?
  • 第三方 SDK 自动采集字段是否有 export 前兜底 masking?

字段分级

  • 是否区分 P0 凭证、P1 个人/客户信息、P2 业务敏感上下文、P3 排障元数据?
  • ID 类字段是否使用稳定哈希,而不是原文?
  • redaction findings 是否进入 trace,方便后续评估规则质量?

RAG / Tool Calling

  • chunk 原文是否默认不进入长期 trace?
  • tool arguments 是否按字段脱敏?
  • tool result 是否有 summary,而不是只存 raw payload?

调试例外

  • 是否有 Debug Escrow,而不是默认全量保留原文?
  • 是否有工单、负责人、原因、过期时间?
  • 是否有访问审计和自动清理?

数据生命周期

  • 脱敏 trace、聚合指标、原文 payload 是否有不同保留期?
  • eval dataset 是否二次脱敏?
  • 导出和备份是否同样执行清理策略?

12. 结论:可观测性不是越完整越好,而是越可控越好

LLM 应用要上线,trace 必不可少。没有 trace,你很难解释模型为什么答错、工具为什么调错、RAG 为什么召回错、某个版本为什么成本突然上涨。

但 trace 越完整,风险也越高。尤其在 Agent 和 RAG 场景里,trace 可能同时包含用户隐私、企业知识、工具参数和模型输出。它不是普通日志,而是生产推理过程的数据副本。

我的建议很直接:

  1. 默认只保留 safe payload;
  2. 用字段分级替代“全靠正则”;
  3. 入口、span 构建、export 前三层兜底;
  4. 原文只进短期 Debug Escrow;
  5. eval 数据入库前二次脱敏;
  6. 用保留期、权限和审计管理 trace 生命周期。

当团队开始这样设计 trace,排障不会消失,反而会更稳定。因为你保留下来的不是一堆未经治理的原文,而是一套能长期使用、能被权限控制、能进入回归评测的数据资产。

LLM 工程进入生产后,真正的成熟不是“我能看到所有东西”,而是“我知道哪些东西该看、谁能看、看多久、看完怎么删”。