智能体日志的价值不取决于数量,而取决于轨迹是否完整、可比较、可复现。本文用 TypeScript 实现一条从原始日志到版本化数据集的流水线,覆盖清洗、脱敏、去重、质量评分和分层导出。 这套流程适合微调、提示词优化和离线评测,但不会把一个分数包装成绝对真相,所有筛选依据都应保留为可解释字段。

先定义轨迹,而不是直接收集日志

智能体的一次运行通常包含用户输入、模型多轮调用、工具调用、工具返回、最终答案和运行状态。工程上最容易犯的错误,是把每一行日志都当成训练样本。这样会带来几个问题:同一任务的中间步骤被重复计算,失败重试占据大量比例,工具异常被误当成模型能力问题,甚至把包含密钥和个人信息的原始内容直接送入下游系统。

本文将一条可用于分析的轨迹定义为一个整体对象,而不是某一条事件:

trace = input + ordered events + final output + outcome + metadata

其中 events 必须保留顺序,outcome 需要区分成功、失败、超时和人工拒绝。不要只用 final_output 是否为空来判断成功,因为有些工具调用失败后,模型仍然会生成一个看似完整但事实错误的回答。

推荐先建立最小字段契约:

字段用途质量风险
traceId关联一次运行重试时可能重复或缺失
input用户任务和上下文可能含个人信息
events模型与工具交互序列事件顺序错乱会改变语义
output最终回答不能单独代表任务成功
status运行结果需要统一枚举值
metadata模型、版本、耗时等信息字段漂移会影响比较

清洗、脱敏和结构校验

清洗顺序会影响结果。先做结构校验,再做内容规范化和脱敏,最后计算指纹;如果在脱敏前计算哈希,相同用户信息会导致样本无法有效聚合,也会增加敏感内容进入索引的风险。

下面的示例只依赖 Node.js 内置模块。将代码保存为 pipeline.ts,使用 npm install -D typescript tsx 后运行 npx tsx pipeline.ts。它演示了一个可运行的单文件版本:读取内存中的原始轨迹,过滤非法记录,脱敏,规范化文本,计算去重指纹,并输出质量分层结果。

import { createHash } from "node:crypto";

type Status = "success" | "failed" | "timeout" | "rejected";
type EventKind = "model" | "tool" | "tool_result";

interface RawEvent {
  kind: EventKind;
  name?: string;
  content?: string;
}

interface RawTrace {
  traceId: string;
  input?: string;
  events?: RawEvent[];
  output?: string;
  status?: string;
  model?: string;
}

interface CleanTrace {
  traceId: string;
  input: string;
  events: RawEvent[];
  output: string;
  status: Status;
  model: string;
  fingerprint: string;
  quality: number;
  tier: "gold" | "silver" | "quarantine";
  reasons: string[];
}

const validStatuses = new Set<Status>(["success", "failed", "timeout", "rejected"]);

function redact(text: string): string {
  return text
    .replace(/sk-[A-Za-z0-9_-]{10,}/g, "[REDACTED_API_KEY]")
    .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g, "[REDACTED_EMAIL]")
    .replace(/\\b1[3-9]\\d{9}\\b/g, "[REDACTED_PHONE]");
}

function normalize(text: string): string {
  return redact(text).normalize("NFKC").replace(/\\s+/g, " ").trim().toLowerCase();
}

function fingerprint(trace: Pick<CleanTrace, "input" | "events" | "output">): string {
  const body = JSON.stringify({
    input: normalize(trace.input),
    events: trace.events.map((event) => ({
      kind: event.kind,
      name: event.name ?? "",
      content: normalize(event.content ?? "")
    })),
    output: normalize(trace.output)
  });
  return createHash("sha256").update(body).digest("hex");
}

function clean(raw: RawTrace): CleanTrace | null {
  if (!raw.traceId || !raw.input || !raw.output || !raw.events?.length) return null;
  if (!raw.status || !validStatuses.has(raw.status as Status)) return null;

  const trace = {
    traceId: raw.traceId,
    input: redact(raw.input),
    events: raw.events.map((event) => ({
      kind: event.kind,
      name: event.name ? normalize(event.name) : undefined,
      content: event.content ? redact(event.content) : undefined
    })),
    output: redact(raw.output),
    status: raw.status as Status,
    model: raw.model ?? "unknown"
  };

  const reasons: string[] = [];
  let quality = 100;
  if (trace.status !== "success") {
    quality -= 45;
    reasons.push(`status:${trace.status}`);
  }
  if (trace.events.some((event) => event.kind === "tool" && !event.name)) {
    quality -= 15;
    reasons.push("tool_without_name");
  }
  if (trace.output.length < 20) {
    quality -= 20;
    reasons.push("short_output");
  }
  quality = Math.max(0, quality);

  const withScore = { ...trace, fingerprint: "", quality, tier: "quarantine" as const, reasons };
  withScore.fingerprint = fingerprint(withScore);
  withScore.tier = quality >= 80 ? "gold" : quality >= 50 ? "silver" : "quarantine";
  return withScore;
}

const rawTraces: RawTrace[] = [
  {
    traceId: "run-001",
    input: "请总结订单状态,联系 alice@example.com",
    events: [
      { kind: "tool", name: "get_order", content: "order_id=1001" },
      { kind: "tool_result", content: "已发货" }
    ],
    output: "订单 1001 已发货,预计明天送达。",
    status: "success",
    model: "model-a"
  },
  {
    traceId: "run-002",
    input: "请总结订单状态,联系 alice@example.com",
    events: [
      { kind: "tool", name: "get_order", content: "order_id=1001" },
      { kind: "tool_result", content: "已发货" }
    ],
    output: "订单 1001 已发货,预计明天送达。",
    status: "success",
    model: "model-a"
  },
  {
    traceId: "run-003",
    input: "查询库存",
    events: [{ kind: "tool", content: "warehouse timeout" }],
    output: "暂时无法查询。",
    status: "timeout",
    model: "model-a"
  }
];

const cleaned = rawTraces.map(clean).filter((trace): trace is CleanTrace => trace !== null);
const unique = [...new Map(cleaned.map((trace) => [trace.fingerprint, trace])).values()];

console.log(JSON.stringify({ total: rawTraces.length, valid: cleaned.length, unique: unique.length, data: unique }, null, 2));

示例中的脱敏规则只是边界保护,不是完整的隐私方案。生产环境还应结合字段级访问控制、日志保留期限和人工抽样检查。对于身份证号、地址、医疗信息等内容,正则表达式很容易漏检,必要时应接入经过评估的实体识别组件,并把脱敏命中情况写入审计字段,而不是覆盖原始数据后无法追踪。

近重复检测与失败轨迹隔离

精确哈希只能识别规范化后完全相同的轨迹。实际数据中常见的是近重复:输入只多了时间戳,工具结果多了一段请求 ID,或者模型答案只改变了标点。可以先删除明显的动态字段,再采用 token 集合相似度、编辑距离或向量检索做候选发现。

不要一开始就对全量数据做昂贵的两两比较。较稳妥的处理方式是先按任务类型、工具集合和模型版本分桶,再在桶内比较。对于每个近重复集合保留一个代表样本,代表选择应有明确规则,例如优先选择成功状态、事件更完整、耗时字段齐全且质量分更高的轨迹。被合并的样本不能直接丢弃,应保留 duplicateOfduplicateMethodduplicateScore,这样后续可以解释样本数量为什么变化。

失败轨迹不等于无价值。它们可以用于故障分析、拒答评测和恢复策略训练,但不应和成功示范样本混在同一个微调集合中。建议至少拆成三层:

分层典型条件推荐用途
gold成功、事件完整、无明显敏感信息、通过人工或规则检查高置信微调和核心离线评测
silver基本可用,但存在轻微缺失或只通过自动检查提示词实验、候选训练集
quarantine失败、超时、结构异常、隐私风险或重复关系不明确故障分析和人工复核

这里的分层是数据治理策略,不是模型能力评级。一个成功轨迹也可能因为答案事实错误而进入隔离区,因此质量评分最好增加任务专用检查器,例如 JSON Schema 校验、SQL 执行验证或基于参考答案的关键字段比对。

让质量评分可解释

一个简单、可审计的分数可以从 100 分开始扣分,而不是直接训练一个黑盒质量模型。基础维度包括:状态是否成功、输入输出是否完整、事件链是否闭合、工具调用是否有对应返回、是否存在异常堆栈或敏感信息、答案长度是否合理。

分数必须与原因同时保存。例如 status:timeouttool_without_nameshort_output 比单独的 quality=40 更有用,因为数据工程师可以据此修正规则,也能在版本升级时解释样本数量的变化。评分函数还应区分硬规则和软规则:发现 API 密钥可以直接隔离,输出稍短则只扣分。不要把所有异常都折算成一个不可逆的数字。

对于结果偏置,需要额外记录任务和结果分布。只保留成功轨迹会产生明显的幸存者偏差,模型看不到工具失败、权限不足和用户拒绝等现实情况。可以按任务类型、状态、工具、模型版本和难度分组统计,然后设置采样上限或分层抽样。这样既避免某个高频简单任务淹没数据,也不会为了追求均衡而人为制造不存在的样本。

数据版本与验证清单

清洗结果应是不可变的数据集产物,而不是覆盖原日志。一个版本目录可以包含 traces.jsonlmanifest.jsonquality-report.json。清单至少记录原始输入数量、通过校验数量、去重数量、各分层数量、规则版本、代码提交标识和生成时间。原始日志只允许受控访问,导出的训练集则只包含必要字段。

例如:

{
  "dataset": "agent-traces",
  "version": "2025-03-08.rules-v2",
  "sourceCount": 12000,
  "validCount": 10980,
  "uniqueCount": 8420,
  "tiers": { "gold": 5100, "silver": 2200, "quarantine": 1120 },
  "rulesVersion": "2.1.0",
  "schemaVersion": "1.0.0"
}

发布前可以用固定的验证清单:确认 JSONL 每行都能解析;所有轨迹都包含 schema 版本;指纹在同一版本内唯一;脱敏规则对测试样本全部命中;工具调用和工具返回的数量关系合理;每个分层都保留抽样样本;版本之间的数量变化超过阈值时阻止自动发布。还应建立少量“金标准”样本,由人工确认预期状态和质量等级,作为规则回归测试。

后续的微调、提示词优化和离线评测应分别取数。微调偏向高质量、多样性的成功轨迹;提示词优化可以同时使用成功和失败案例,但必须标注失败原因;离线评测需要固定测试集,不能每次从最新日志中随机抽取,否则指标会随着数据清洗规则一起漂移。数据版本和实验版本要关联起来,才能回答“这次效果变化来自提示词、模型,还是数据集变化”。

总结

智能体数据治理的重点不是把日志收集得更多,而是让每条轨迹具备明确的结构、状态和使用边界。实践中可以按以下顺序落地:

  1. 定义轨迹 schema,区分运行状态、事件序列和最终答案。
  2. 先校验结构,再规范化和脱敏,最后计算内容指纹。
  3. 通过分桶和近重复检测减少样本冗余,并保留合并关系。
  4. 用可解释的规则评分,把 goldsilverquarantine 分开使用。
  5. 对失败样本和结果分布做单独分析,避免只保留成功案例造成偏置。
  6. 输出不可变的数据版本、清单和质量报告,为实验结果提供可追溯依据。

这条流水线不替代人工判断,也不保证数据天然正确。它的价值在于把隐含的筛选标准变成代码、字段和版本,让团队可以复查每一次数据变化,并把更多精力放在任务定义和模型行为本身。