Agent CLI深水区 - TraceEval调试体系
Agent CLI 深水区 10:Trace、Eval 与调试体系
为什么必须有 Trace
AI Agent 出错时,不像普通接口只看 stack trace 就能定位。你在 Java 后端熟悉的排查三件套是异常堆栈、日志、链路追踪(比如 SkyWalking 用 traceId 把一次请求经过的所有服务串起来)。
Agent 的 trace 干的就是链路追踪这件事:把一次任务里模型、工具、权限的每一步都记下来。
一次错误可能来自:
- 上下文没放对。
- 模型选错工具。
- 工具参数错。
- 权限拒绝。
- 工具输出被截断。
- 模型忽略工具结果。
- prompt 版本变化。
- RAG 召回错(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,因为每发生一步就往文件末尾追加一行,不用像存成一个大 JSON 数组那样每次重写整个文件,和往日志文件里 append 一行日志是一回事。
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 应该记录什么
把上面 TraceEvent 里的 phase 逐个过一遍,每个阶段至少存这些字段:
| 阶段 | 记录 |
|---|---|
| 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、最终输出摘要 |
stop reason 指模型这轮为什么停下来:正常答完、还想调用工具、达到 token 上限被截断等。
注意: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(evaluation,评测)说白了就是给 Agent 准备的一套固定回归测试集,地位和你 Java 项目里的 JUnit 测试套件一样。每次改 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:只做字符串包含和工具名比对,完全不碰语义。更高级的做法是让另一个模型来给回答打分,业界叫 LLM-as-judge,但先把这套最小版本跑起来再说。
Eval 分类
按测试目的给用例分层,思路和后端测试里冒烟、功能、回归的分层一样。Smoke 就是冒烟测试,只验证最基本的流程能跑通:
| 类型 | 测什么 |
|---|---|
| Smoke | 基本能跑 |
| Tool | 是否调用正确工具 |
| Safety | 是否拒绝危险动作 |
| RAG | 是否引用正确资料 |
| Format | JSON/Markdown 是否合规 |
| Regression | 过去 bug 是否复现 |
Benchmark 地图:不同失败用不同基准
Benchmark 就是业界公开的标准测试集,类似数据库圈拿 TPC-C 跑分对比性能。Hello Agents(Datawhale 社区出的一本开源 Agent 入门教程,在 GitHub 上可以免费读,不用专门买书)第 12 章给出了工具调用、通用任务和多智能体评估的分层。
这些公共 benchmark 不必全部跑,它们的主要价值是告诉你:测某类能力时,业界用什么方式当 Judge(判定器)。
下表每行一个 benchmark,第二列是它测什么,第三列是它的判分方式(可以直接抄来自己用),最后一列提醒你它测不到什么。
| Benchmark | 主要测试 | 适合借鉴的 Judge | 不覆盖什么 |
|---|---|---|---|
| BFCL | 函数选择、参数、并行/多调用 | AST/结构化调用匹配 | 完整业务任务是否成功 |
| ToolBench / API-Bank | API 文档理解与工具链 | 调用轨迹、参数、最终结果 | 私有系统权限边界 |
| GAIA | 搜索、文件、多步推理、综合助手 | 准精确答案 + 任务证据 | 具体代码库修改质量 |
| WebArena | 真实网页任务 | 页面状态和任务完成 | 本地文件/CLI Agent |
| SWE-bench | 真实 issue 修复 | 测试是否通过、patch 是否正确 | 通用知识问答 |
| AgentBench | 多环境通用能力 | 环境任务完成率 | 自己产品的长期回归 |
代码块收起展开
几个名字展开说一下:BFCL 是 Berkeley Function Calling Leaderboard,专测模型会不会正确选函数、填参数;
ToolBench 和 API-Bank 测模型读了 API 文档后能不能把一串工具调用串对;
GAIA 是一套要靠搜索、读文件、多步推理才答得出来的综合助手题;
WebArena 让 Agent 在仿真网站上完成订票、购物这类真实网页任务;
SWE-bench 从 GitHub 真实 issue 出题,判定标准是修完之后项目测试能不能跑过;AgentBench 把 Agent 扔进操作系统、数据库、游戏等多种环境测通用能力。
表里的“AST/结构化调用匹配”指按语法结构逐项比对函数名和参数(AST 即抽象语法树),而不是比整个输出字符串。
选择原则:Tool schema 改动先跑 BFCL 类型用例;Browser Harness 改动参考 WebArena(Harness 指模型外面那层工程壳,prompt、工具、上下文管理、权限策略加起来才是 Harness);Coding Agent 改动参考 SWE-bench 的“真实任务 + 测试判定”方式。
最后仍要维护自己的 golden set(自己攒的一小批带标准答案的用例,那份标准答案就叫 golden answer),因为公共 benchmark 不知道本项目的权限、目录和产品约束。
区分模型能力与 Harness 质量
同一模型套上不同 Harness 可能得到完全不同的结果。所以要验证“失败是不是模型不行”,必须固定模型和任务,只替换一个 Harness 变量做消融实验(ablation:一次只动一个变量、其余全固定,结果差异才能归到这个变量头上,和你压测时一次只改一个 JVM 参数是同一个纪律)。
下表每行是一种消融实验,固定项是这轮不许动的东西,变量是唯一允许改的东西:
| 实验 | 固定项 | 变量 | 观察指标 |
|---|---|---|---|
| Tool Schema A/B | 模型、任务、工具实现 | name/description/schema | 工具选择率、参数准确率 |
| Context A/B | 模型、任务 | 预加载还是 JIT 按需检索(用到时才去取资料)、排序、压缩 | 成功率、token、规则遗忘 |
| Skill A/B | 模型、任务 | description/正文/示例 | 触发准确率、完成率 |
| Permission A/B | 模型、工具 | 自动允许/人工确认/规则 | 危险执行、误阻断、打断次数 |
| Recovery A/B | 模型、失败输入 | 重试、换路径、压缩、降级 | 恢复率、重复失败率 |
| Single/Multi | 模型、任务、工具 | 单 Agent/协作协议 | 成功率、成本、延迟、重复劳动 |
只有当更好的上下文、工具和反馈仍无法让固定任务通过,才更有证据指向模型能力边界。否则“换更强模型”可能只是掩盖 Harness 缺陷。
Harness EvalCase 需要记录配置指纹
代码块收起展开
type HarnessFingerprint = {
model: string;
systemPromptHash: string;
toolSchemaHash: string;
skillSetHash: string;
contextPolicyVersion: string;
permissionPolicyVersion: string;
recoveryPolicyVersion: string;
};同一 case 没有配置指纹,就无法判断结果变化来自模型、Prompt、Tool、Skill 还是上下文策略。
调试问题的流程
当 Agent 答错,不要先改 prompt。按这个顺序:
- 看 trace:模型看到什么 context?
- 看 tool calls:有没有选错工具?
- 看 tool result:工具有没有返回正确内容?
- 看 compaction:重要信息是否被压缩掉?
- 看 final prompt:是否指令冲突?
- 看模型输出:是推理错还是资料缺?
- 再决定改 prompt、工具、RAG、还是上下文。
下面这张失败归因矩阵这样读:左边是你观察到的现象,往右依次是最可能的根因、去 trace 里找哪条证据、事后往 eval 里补哪类用例:
| 现象 | 更可能的根因 | 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 系统的发布闸门:跑不过就不上线,位置等同于 CI 里测试挂了不许合并。演示前随手问几个问题不算 eval。普通后端可以靠单元测试保证确定性逻辑,Agent 还要额外验证:模型选择、工具调用、RAG 证据、上下文压缩、拒答边界、成本预算是否一起退化。
一套可落地的质量门禁至少分四层:
| 层级 | 测什么 | 输入来源 | 失败后改哪里 |
|---|---|---|---|
| Contract Eval | schema、JSON 格式、工具参数是否合法 | 人工构造边界用例 | DTO/schema/parser |
| Behavior Eval | 是否选对工具、能否拒答、是否引用证据 | 固定任务集 | prompt、tool description、router |
| Regression Eval | 历史事故是否复现 | debug report 转换而来 | 对应事故的根因模块 |
| Canary Eval | 新版本在线小流量是否退化 | 真实请求抽样脱敏 | 灰度策略、回滚、预算 |
Canary 就是灰度发布(金丝雀发布):新版本先只接一小部分真实流量,指标没退化再逐步放量,和后端上线新服务先切 5% 流量观察是一回事。
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 策略 |
表里几个词解释一下:p95Latency 是 95 分位延迟(100 个请求里排第 95 慢的那个耗时,比平均值更能暴露长尾卡顿);topK 是 RAG 检索时取相似度前 K 条资料,K 越大上下文越贵;groundedness 指回答是否落在检索到的证据上,这个指标掉了说明模型开始脱离资料自由发挥;prompt injection 指网页、文件、插件输出这类外部内容里藏了指令来劫持模型,角色类似 SQL 注入之于 SQL。
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。
};severity 里的 P0/P1/P2 是事故优先级分档:P0 最严重,核心功能不可用,要立刻处理甚至回滚;P1 影响质量,需要快速止血;P2 可以排期修。
一个实际例子:
代码块收起展开
{
"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 或上下文,改 prompt 只是碰运气 |
| 只看最终回答 | 中间选了什么工具、传了什么参数全看不到,等于只看接口返回不看日志 |
| 不保存 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。