Agent CLI深水区 - TraceEval调试体系 · AI Agent

Agent CLI 深水区 10:Trace、Eval 与调试体系

为什么必须有 Trace

AI Agent 出错时,不像普通接口只看 stack trace。

一次错误可能来自:

  • 上下文没放对。
  • 模型选错工具。
  • 工具参数错。
  • 权限拒绝。
  • 工具输出被截断。
  • 模型忽略工具结果。
  • prompt 版本变化。
  • RAG 召回错。

没有 trace,只能凭感觉猜。

Trace/Eval 调试闭环 Agent 任务 Trace Debug Report 根因分类 Eval Context Tool Model Memory 修复策略 回归用例 pass fail -> 继续 debug

核心关系:Trace 负责还原事实,Debug Report 负责归因,Eval 用固定用例防止下次改坏。

TraceEvent

代码块TS · 18 行收起展开
// TraceEvent = Agent 内部发生的一条调试事件。
// 它不是聊天记录,而是工程排查用的“链路日志”。
type TraceEvent = {
  sessionId: string; // 哪个会话产生的事件。
  traceId: string;   // 当前事件 ID,方便串联一次任务里的多个步骤。
  time: string;      // 事件发生时间。
  phase:
    | "context_built"       // 已构建上下文:记录放了哪些 messages、token 多少。
    | "model_request"       // 准备调模型:记录模型名、工具列表、prompt 版本。
    | "model_response"      // 模型返回:记录文本、工具调用、usage、latency。
    | "tool_start"          // 工具开始执行:记录工具名和输入。
    | "tool_result"         // 工具成功返回:记录摘要、输出大小、是否截断。
    | "tool_error"          // 工具失败:记录错误码和错误信息。
    | "permission_decision" // 权限决策:allow/deny/ask 及原因。
    | "compaction"          // 上下文压缩:压缩前后 token 和摘要。
    | "final_answer";       // 最终回答:记录 stop reason 和摘要。
  data: unknown;            // 各阶段自己的结构化数据,真实系统要定义更细 schema。
};

写入:

代码块TS · 10 行收起展开
// appendTrace = 把一条 trace 追加到本地 JSONL 文件。
// JSONL 一行一个 JSON,适合流式追加,也方便后续按行读取分析。
export async function appendTrace(event: TraceEvent) {
  await appendFile(
    // 每个 session 一个 trace 文件,便于按会话排查。
    `.agent/traces/${event.sessionId}.jsonl`,
    // 追加一行 JSON。注意真实系统要做敏感信息脱敏。
    JSON.stringify(event) + "\n"
  );
}

JSONL 适合 trace,因为每一步追加即可。

Trace 粒度不要太粗,也不要把所有正文都存进去:

粒度太少会怎样太多会怎样推荐记录
context不知道模型看见什么泄露隐私、文件过大part 名称、token、摘要、关键 source
model不知道模型为何选工具存完整 prompt 成本高model、promptVersion、tool schema hash、usage
tool不知道动作是否执行大输出污染 tracename、input 摘要、summary、bytes、error
permission不知道谁批准无意义重复decision type、source、reason
eval只知道失败报告难读case id、expected、actual、failures

Trace 应该记录什么

阶段记录
context_builtmessages 摘要、token 估算、包含哪些部分
model_requestmodel、tools、prompt version
model_responsetext/tool_call、usage、latency
permission_decisionallow/deny/ask、原因
tool_starttool name、input
tool_resultsummary、output bytes、truncated
tool_errorerror code、message
compaction压缩前后 token、摘要
final_answerstop reason、最终输出摘要

注意:trace 可能包含敏感内容。公开前要脱敏。

Trace 和 Transcript 的区别

名称目的内容
Transcript会话恢复用户/助手/工具消息
Trace调试复盘内部状态、token、工具、错误

Transcript 给模型继续对话用。Trace 给工程师 debug 用。

Trace 装饰器

可以写一个 helper:

代码块TS · 57 行收起展开
// traced = 给任意异步函数加 trace 的包装器。
// 用法:await traced(sessionId, "tool_start", { tool: "grep" }, () => runGrep())
export async function traced<T>(
  sessionId: string,             // 当前会话 ID。
  phase: TraceEvent["phase"],    // 当前阶段名,例如 model_request/tool_start。
  data: unknown,                 // 阶段相关数据,例如工具名、输入摘要。
  fn: () => Promise<T>           // 真正要执行的业务函数。
): Promise<T> {
  // 记录开始时间,用于计算耗时。
  const startedAt = Date.now();

  // 先记录 start 事件。即使后面卡住,也能知道卡在哪个阶段。
  await appendTrace({
    sessionId,
    traceId: crypto.randomUUID(),
    time: new Date().toISOString(),
    phase,
    data: { ...data, status: "start" }
  });

  try {
    // 执行业务函数。
    const result = await fn();

    // 成功后记录 success 和耗时。
    await appendTrace({
      sessionId,
      traceId: crypto.randomUUID(),
      time: new Date().toISOString(),
      phase,
      data: {
        status: "success",
        durationMs: Date.now() - startedAt
      }
    });

    // 把原函数结果原样返回,不改变业务行为。
    return result;
  } catch (error) {
    // 异常时记录 error 和耗时。
    await appendTrace({
      sessionId,
      traceId: crypto.randomUUID(),
      time: new Date().toISOString(),
      phase,
      data: {
        status: "error",
        durationMs: Date.now() - startedAt,
        // Error 对象取 message;非 Error 对象转字符串。
        error: error instanceof Error ? error.message : String(error)
      }
    });

    // 继续抛出,让上层按原本逻辑处理失败。
    throw error;
  }
}

Eval 是什么

Eval 是固定测试集。每次改 prompt、模型、RAG、工具策略,都跑一遍。

代码块TS · 12 行收起展开
// EvalCase = 一个固定测试用例。
// 每次改 prompt、工具策略、RAG 检索,都可以跑这些用例防回归。
type EvalCase = {
  id: string;    // 用例 ID,例如 tool-read-001。
  input: string; // 模拟用户输入。
  expected: {
    mustContain?: string[];    // 最终文本必须包含的关键词。
    mustNotContain?: string[]; // 最终文本不能出现的内容。
    toolCalls?: string[];      // 期望调用的工具名列表。
    stopReason?: StopReason;   // 期望停止原因,例如 final_answer。
  };
};

例子:

代码块JSON · 9 行收起展开
{
  "id": "tool-read-001",
  "input": "读取 README 并总结项目目标",
  "expected": {
    "toolCalls": ["read_file"],
    "mustContain": ["项目"],
    "stopReason": "final_answer"
  }
}

Eval Runner

代码块TS · 22 行收起展开
// runEval = 批量运行评测用例。
// 输入固定测试集,输出通过/失败统计。
export async function runEval(cases: EvalCase[]) {
  const results: EvalResult[] = [];

  for (const item of cases) {
    // query(item.input) 会产生 Agent 事件流:文本、工具调用、工具结果等。
    const events = await collectEvents(query(item.input));

    // judge 根据 expected 判断这次运行是否达标。
    const result = judge(item, events);

    // 收集每个用例结果。
    results.push(result);
  }

  return {
    total: results.length,                         // 总用例数。
    passed: results.filter(r => r.passed).length,  // 通过数量。
    failed: results.filter(r => !r.passed)         // 失败明细。
  };
}

收集事件:

代码块TS · 12 行收起展开
// collectEvents = 把 Agent 的异步事件流收集成数组。
// Eval 需要完整事件列表,才能判断文本内容和工具调用。
async function collectEvents(stream: AsyncGenerator<AgentEvent>) {
  const events: AgentEvent[] = [];

  // for await 可以消费流式输出。
  for await (const event of stream) {
    events.push(event);
  }

  return events;
}

Eval 的执行链路:

flowchart TD
  Suite["EvalSuite cases"] --> Runner["EvalRunner"]
  Runner --> Agent["QueryEngine"]
  Agent --> Events["AgentEvent stream"]
  Events --> Judge["Judge"]
  Judge --> Result["pass/fail + failures"]
  Result --> Runner
  Runner --> Report["aggregate metrics"]

Judge

代码块TS · 44 行收起展开
// judge = 最小评测判定器。
// 它不理解语义,只检查文本包含关系和工具调用是否符合预期。
function judge(test: EvalCase, events: AgentEvent[]): EvalResult {
  // 把所有文本增量拼成最终回答文本。
  const text = events
    .filter(e => e.type === "text_delta")
    .map(e => e.text)
    .join("");

  // 从事件里提取实际调用过的工具名。
  const toolCalls = events
    .filter(e => e.type === "tool_start")
    .map(e => e.call.name);

  // failures 为空表示通过;非空表示失败原因列表。
  const failures: string[] = [];

  // 检查必须出现的关键词。
  for (const needle of test.expected.mustContain ?? []) {
    if (!text.includes(needle)) {
      failures.push(`Missing text: ${needle}`);
    }
  }

  // 检查禁止出现的内容。
  for (const forbidden of test.expected.mustNotContain ?? []) {
    if (text.includes(forbidden)) {
      failures.push(`Forbidden text found: ${forbidden}`);
    }
  }

  // 检查期望工具是否被调用。
  for (const tool of test.expected.toolCalls ?? []) {
    if (!toolCalls.includes(tool)) {
      failures.push(`Expected tool not called: ${tool}`);
    }
  }

  return {
    id: test.id,                 // 用例 ID。
    passed: failures.length === 0, // 没有失败原因就算通过。
    failures                     // 失败原因,给调试报告用。
  };
}

这是最小 eval。更高级可以让另一个模型评分,但先别急。

Eval 分类

类型测什么
Smoke基本能跑
Tool是否调用正确工具
Safety是否拒绝危险动作
RAG是否引用正确资料
FormatJSON/Markdown 是否合规
Regression过去 bug 是否复现

调试问题的流程

当 Agent 答错,不要先改 prompt。按这个顺序:

  1. 看 trace:模型看到什么 context?
  2. 看 tool calls:有没有选错工具?
  3. 看 tool result:工具有没有返回正确内容?
  4. 看 compaction:重要信息是否被压缩掉?
  5. 看 final prompt:是否指令冲突?
  6. 看模型输出:是推理错还是资料缺?
  7. 再决定改 prompt、工具、RAG、还是上下文。

失败归因矩阵:

现象更可能的根因Trace 证据Eval 该怎么补
没调用工具prompt/tool schema 不清楚model_response 无 tool_call增加期望 toolCalls 用例
调错工具工具描述冲突相似工具都在 tools 列表加工具选择对比用例
参数错schema 不够明确或上下文缺字段tool_error: TOOL_INPUT_INVALID加参数边界用例
答案编造RAG 没召回或没引用context 无 source / source 不相关加 mustContain sourcePath
忘记规则system/project rules 被压缩context_built 缺规则 part加安全/格式回归
成本暴涨上下文或工具输出过大usage、outputBytes 上升加 token budget 阈值
长任务中断compaction 丢关键信息compaction summary 缺下一步加多轮续跑用例

Debug Report

可以生成调试报告:

代码块TS · 17 行收起展开
// DebugReport = 一次失败任务的结构化复盘报告。
// 适合保存到 Obsidian,之后能按“上下文/工具/模型/验证”逐项排查。
type DebugReport = {
  sessionId: string;       // 会话 ID。
  userInput: string;       // 用户原始问题。
  model: string;           // 使用的模型。
  promptVersion: string;   // 使用的 prompt 版本,方便回归定位。
  contextParts: string[];  // 本轮上下文包含哪些部分:memory、git、files、rag 等。
  toolCalls: Array<{
    name: string;      // 工具名。
    ok: boolean;       // 工具是否成功。
    summary?: string;  // 成功摘要。
    error?: string;    // 失败错误。
  }>;
  stopReason: StopReason; // 模型停止原因。
  tokenUsage: TokenUsage; // token 用量,排查成本和上下文压力。
};

这类报告可以直接放进 Obsidian,形成学习复盘。

指标

指标意义
task success rate任务成功率
tool accuracy工具选择是否正确
permission denial rate权限拒绝比例
avg turns平均模型轮次
avg tool calls平均工具调用
token per task成本
timeout rate性能问题
context truncation rate上下文压力

Prompt 版本管理

代码块TS · 8 行收起展开
// PromptVersion = 一个 prompt 版本记录。
// prompt 一变,eval 结果可能变,所以必须可追踪。
type PromptVersion = {
  id: string;          // 版本 ID,例如 agent-base-2026-07-05。
  name: string;        // 人类可读名称。
  contentHash: string; // prompt 内容 hash,用来确认具体文本是否变过。
  createdAt: string;   // 创建时间。
};

Trace 里必须记录 prompt version:

代码块JSON · 7 行收起展开
{
  "phase": "model_request",
  "data": {
    "model": "default",
    "promptVersion": "agent-base-2026-07-05"
  }
}

否则 eval 变好或变坏时,不知道是哪次 prompt 改动导致的。

从离线 Eval 到发布门禁

Eval 不是“演示前跑几个问题”,而是 Agent 系统的发布闸门。普通后端可以靠单元测试保证确定性逻辑,Agent 还要额外验证:模型选择、工具调用、RAG 证据、上下文压缩、拒答边界、成本预算是否一起退化。

一套可落地的质量门禁至少分四层:

层级测什么输入来源失败后改哪里
Contract Evalschema、JSON 格式、工具参数是否合法人工构造边界用例DTO/schema/parser
Behavior Eval是否选对工具、能否拒答、是否引用证据固定任务集prompt、tool description、router
Regression Eval历史事故是否复现debug report 转换而来对应事故的根因模块
Canary Eval新版本在线小流量是否退化真实请求抽样脱敏灰度策略、回滚、预算

Eval case 要写成工程对象,不要只写一个问题和标准答案。

代码块TS · 26 行收起展开
// AgentEvalCase = 一个可重复执行的 Agent 评测样本。
// 它不仅描述“问什么”,还描述期望经过哪些中间行为。
type AgentEvalCase = {
  id: string;                         // 稳定 ID,失败报告和历史趋势都靠它关联。
  category: "rag" | "tool" | "safety" | "format" | "memory" | "workflow";
  task: string;                       // 用户输入,尽量接近真实任务而不是关键词测试。
  fixtureVersion: string;             // 知识库、工具 schema、prompt 的夹具版本。

  expected: {
    finalAnswerMustContain?: string[]; // 最终答案必须包含的事实或字段。
    finalAnswerMustNotContain?: string[]; // 防止编造、泄露、越权措辞。
    requiredToolCalls?: string[];      // 必须调用的工具名。
    forbiddenToolCalls?: string[];     // 安全用例中绝不能调用的工具名。
    requiredSources?: string[];        // RAG 用例要求命中的来源。
    shouldRefuse?: boolean;            // 无资料、越权、危险操作时必须拒答。
  };

  probes: {
    tracePhases: string[];             // 必须出现的 trace 阶段,例如 retrieve/tool_call/model_response。
    maxTurns?: number;                 // 防止工作流绕圈。
    maxToolCalls?: number;             // 防止工具滥用。
    maxTokens?: number;                // 成本预算。
  };

  regressionOf?: string;               // 如果来自历史事故,记录事故/debug report ID。
};

发布时不要只看总分,要看“哪类能力退化”。一个版本能不能放量,按下面的顺序判断:

判断项通过条件一票否决
安全safety case 全过越权读写、危险命令执行、敏感内容泄露
格式结构化输出可解析JSON/schema 破坏导致下游崩溃
RAG必要来源命中,无法回答时拒答没证据却编答案
工具工具选择和参数稳定调错写入类工具或重复副作用
回归历史事故不复现旧 bug 回来
成本token、耗时、工具次数在预算内为小问题消耗大上下文或长循环

一次 Eval 结果应该保存成差异报告,而不是只保存 pass/fail。

代码块JSON · 17 行收起展开
{
  "evalRunId": "run-2026-07-06-001",
  "promptVersion": "agent-base-2026-07-06",
  "retrieverVersion": "notes-index-v3",
  "toolSchemaVersion": "tools-2026-07-06",
  "caseId": "rag-no-evidence-refuse",
  "passed": false,
  "failureType": "rag_grounding",
  "expected": "shouldRefuse=true",
  "actual": "answered_without_source",
  "traceId": "trace-abc",
  "diff": {
    "selectedChunks": [],
    "toolCalls": ["search_notes"],
    "tokenUsage": 1840
  }
}

这个 JSON 的意义是:失败能被定位到版本、模块和 trace。否则“今天感觉没昨天好”没有工程价值。

灰度发布可以用很简单的规则开始:

  1. 新 prompt / RAG / tool schema 先跑离线 eval。
  2. 离线通过后只给小比例真实请求使用。
  3. 在线采样 trace,抽取失败样本转成 regression eval。
  4. safety 或 format 一票否决,立即回滚。
  5. 普通质量退化按类别修复,不要先改 prompt 试运气。

告警与事故 Runbook

Trace/Eval 的最终目的不是生成好看的报表,而是在出问题时缩短定位时间。生产系统要把“指标异常 -> trace 证据 -> 根因分类 -> 修复动作 -> 回归用例”串起来。

告警触发条件第一证据处理动作
成功率下降taskSuccessRate 连续低于阈值failureType 分布、recentVersion按失败类别回滚 prompt/RAG/tool
延迟升高p95Latency 或 queueDepth 超阈值modelLatency、toolLatency、retryCount限流、降级模型、关闭慢工具
成本异常tokenPerTask 或 costPerTask 突增contextParts、topK、outputBytes降低 topK、截断工具输出、启用缓存
安全拒绝异常permissionDeniedRate 突增deniedReason、toolName、source检查 prompt injection 或插件变更
幻觉上升groundedness eval 下降selectedSources、answerCitations回滚索引/prompt,补 RAG regression
格式失败schemaParseError 增加rawOutput、parserError、promptVersion回滚输出协议或加 repair 策略

Runbook 不要写成自然语言备忘录,应该能直接转成值班检查单:

代码块TS · 11 行收起展开
// IncidentRunbook = 一类 Agent 事故的处置脚本。
// 它把告警、证据、止血、根因、回归样本连在一起。
type IncidentRunbook = {
  id: string;                         // 稳定 ID,例如 rag-grounding-regression。
  alertName: string;                  // 告警名,和监控系统对应。
  severity: "P0" | "P1" | "P2";       // 严重程度,决定是否立即回滚。
  firstChecks: string[];              // 值班第一轮必须看的 trace/eval 字段。
  mitigation: string[];               // 止血动作,例如回滚、限流、关闭工具。
  rootCauseFields: string[];          // 根因分析要对比的版本和证据。
  regressionCaseTemplate: string;     // 事故复盘后沉淀成哪类 eval case。
};

一个实际例子:

代码块JSONC · 17 行收起展开
{
  "id": "rag-grounding-regression",              // RAG 证据退化事故。
  "alertName": "groundedness_eval_drop",         // groundedness 指标下降。
  "severity": "P1",                              // 影响回答质量,需要快速止血。
  "firstChecks": [
    "promptVersion",                             // 是否刚改过 prompt。
    "retrieverVersion",                          // 是否刚重建索引或改检索策略。
    "selectedChunkIds",                          // 模型看到的证据是什么。
    "answerCitations"                            // 最终引用是否来自这些证据。
  ],
  "mitigation": [
    "rollback retrieverVersion",                 // 先回滚检索策略。
    "reduce traffic to new prompt",              // 新 prompt 降流。
    "force refusal when requiredSources missing" // 没证据时强制拒答。
  ],
  "regressionCaseTemplate": "rag_required_sources" // 事故沉淀成 RAG 来源命中回归用例。
}

事故复盘要产出四个东西:

产物作用
DebugReport说明这次为什么错
EvalCase保证下次不再错
VersionDiff说明哪次变更引入退化
RunbookPatch更新值班步骤,减少下次排查时间

如果一次事故没有转成 eval case,它就只是被临时修掉;如果没有更新 runbook,下次还会从头猜。

常见调试误区

误区问题
一错就改 prompt可能是工具/RAG/上下文问题
只看最终回答看不到中间决策
不保存 tool result无法复盘
eval 全靠人工感觉质量不可回归
不记录 prompt 版本改坏了无法回滚

读源码时看 Trace/Eval

重点找:

  1. 是否保存 transcript。
  2. 是否保存内部 trace。
  3. model request 是否可复盘。
  4. tool call 是否有 audit。
  5. usage 是否记录。
  6. prompt version 是否记录。
  7. 是否有 eval runner。
  8. 是否能测试工具调用链。

Trace/Eval 是从 demo 到工程的分界线。

最小落地顺序:

  1. 先记录 TraceEvent JSONL,不急着做漂亮 UI。
  2. 给工具调用补 tool_start/tool_result/tool_error
  3. 给模型请求补 model_request/model_response/usage
  4. 保存失败任务的 DebugReport
  5. 把失败案例转成 EvalCase
  6. 每次改 prompt、工具、RAG、上下文策略前后跑 eval。