Agent CLI深水区 - TraceEval调试体系 · AI Agent
Agent CLI 深水区 10:Trace、Eval 与调试体系
为什么必须有 Trace
AI Agent 出错时,不像普通接口只看 stack trace。
一次错误可能来自:
- 上下文没放对。
- 模型选错工具。
- 工具参数错。
- 权限拒绝。
- 工具输出被截断。
- 模型忽略工具结果。
- prompt 版本变化。
- RAG 召回错。
没有 trace,只能凭感觉猜。
核心关系:Trace 负责还原事实,Debug Report 负责归因,Eval 用固定用例防止下次改坏。
TraceEvent
代码块收起展开
// 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。
};写入:
代码块收起展开
// 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 | 不知道动作是否执行 | 大输出污染 trace | name、input 摘要、summary、bytes、error |
| permission | 不知道谁批准 | 无意义重复 | decision type、source、reason |
| eval | 只知道失败 | 报告难读 | case id、expected、actual、failures |
Trace 应该记录什么
| 阶段 | 记录 |
|---|---|
| context_built | messages 摘要、token 估算、包含哪些部分 |
| model_request | model、tools、prompt version |
| model_response | text/tool_call、usage、latency |
| permission_decision | allow/deny/ask、原因 |
| tool_start | tool name、input |
| tool_result | summary、output bytes、truncated |
| tool_error | error code、message |
| compaction | 压缩前后 token、摘要 |
| final_answer | stop reason、最终输出摘要 |
注意:trace 可能包含敏感内容。公开前要脱敏。
Trace 和 Transcript 的区别
| 名称 | 目的 | 内容 |
|---|---|---|
| Transcript | 会话恢复 | 用户/助手/工具消息 |
| Trace | 调试复盘 | 内部状态、token、工具、错误 |
Transcript 给模型继续对话用。Trace 给工程师 debug 用。
Trace 装饰器
可以写一个 helper:
代码块收起展开
// 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、工具策略,都跑一遍。
代码块收起展开
// EvalCase = 一个固定测试用例。
// 每次改 prompt、工具策略、RAG 检索,都可以跑这些用例防回归。
type EvalCase = {
id: string; // 用例 ID,例如 tool-read-001。
input: string; // 模拟用户输入。
expected: {
mustContain?: string[]; // 最终文本必须包含的关键词。
mustNotContain?: string[]; // 最终文本不能出现的内容。
toolCalls?: string[]; // 期望调用的工具名列表。
stopReason?: StopReason; // 期望停止原因,例如 final_answer。
};
};例子:
代码块收起展开
{
"id": "tool-read-001",
"input": "读取 README 并总结项目目标",
"expected": {
"toolCalls": ["read_file"],
"mustContain": ["项目"],
"stopReason": "final_answer"
}
}Eval Runner
代码块收起展开
// 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) // 失败明细。
};
}收集事件:
代码块收起展开
// 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
代码块收起展开
// 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 | 是否引用正确资料 |
| Format | JSON/Markdown 是否合规 |
| Regression | 过去 bug 是否复现 |
调试问题的流程
当 Agent 答错,不要先改 prompt。按这个顺序:
- 看 trace:模型看到什么 context?
- 看 tool calls:有没有选错工具?
- 看 tool result:工具有没有返回正确内容?
- 看 compaction:重要信息是否被压缩掉?
- 看 final prompt:是否指令冲突?
- 看模型输出:是推理错还是资料缺?
- 再决定改 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
可以生成调试报告:
代码块收起展开
// 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 版本管理
代码块收起展开
// 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:
代码块收起展开
{
"phase": "model_request",
"data": {
"model": "default",
"promptVersion": "agent-base-2026-07-05"
}
}否则 eval 变好或变坏时,不知道是哪次 prompt 改动导致的。
从离线 Eval 到发布门禁
Eval 不是“演示前跑几个问题”,而是 Agent 系统的发布闸门。普通后端可以靠单元测试保证确定性逻辑,Agent 还要额外验证:模型选择、工具调用、RAG 证据、上下文压缩、拒答边界、成本预算是否一起退化。
一套可落地的质量门禁至少分四层:
| 层级 | 测什么 | 输入来源 | 失败后改哪里 |
|---|---|---|---|
| Contract Eval | schema、JSON 格式、工具参数是否合法 | 人工构造边界用例 | DTO/schema/parser |
| Behavior Eval | 是否选对工具、能否拒答、是否引用证据 | 固定任务集 | prompt、tool description、router |
| Regression Eval | 历史事故是否复现 | debug report 转换而来 | 对应事故的根因模块 |
| Canary Eval | 新版本在线小流量是否退化 | 真实请求抽样脱敏 | 灰度策略、回滚、预算 |
Eval case 要写成工程对象,不要只写一个问题和标准答案。
代码块收起展开
// 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。
代码块收起展开
{
"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。否则“今天感觉没昨天好”没有工程价值。
灰度发布可以用很简单的规则开始:
- 新 prompt / RAG / tool schema 先跑离线 eval。
- 离线通过后只给小比例真实请求使用。
- 在线采样 trace,抽取失败样本转成 regression eval。
- safety 或 format 一票否决,立即回滚。
- 普通质量退化按类别修复,不要先改 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 不要写成自然语言备忘录,应该能直接转成值班检查单:
代码块收起展开
// 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。
};一个实际例子:
代码块收起展开
{
"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
重点找:
- 是否保存 transcript。
- 是否保存内部 trace。
- model request 是否可复盘。
- tool call 是否有 audit。
- usage 是否记录。
- prompt version 是否记录。
- 是否有 eval runner。
- 是否能测试工具调用链。
Trace/Eval 是从 demo 到工程的分界线。
最小落地顺序:
- 先记录
TraceEventJSONL,不急着做漂亮 UI。 - 给工具调用补
tool_start/tool_result/tool_error。 - 给模型请求补
model_request/model_response/usage。 - 保存失败任务的
DebugReport。 - 把失败案例转成
EvalCase。 - 每次改 prompt、工具、RAG、上下文策略前后跑 eval。