作者:前端转 AI 深度实践者
【省流助手/核心观点】:Agent 不是只要能调用工具就能上线。一次 Agent 运行可能包含计划创建、工具调用、重试、降级、暂停、跳过和最终回答。没有
traceId、结构化事件和运行报告,Agent 出错后只能靠猜。真正可上线的 Agent,必须让每一步都能被追踪、搜索、统计和解释。对前端开发者来说,这就是把埋点、错误上报、性能监控和 requestId 的工程经验迁移到 AI Agent 上。
做到第 27 篇时,我们的 Agent 已经越来越像一个工程系统了。
它能调用工具。
它有工具 Schema。
它能跑 Agent Loop。
它能先计划再行动。
它遇到失败时还能重试、降级、暂停或停止。
听起来不错。
但只要准备把它交给真实用户,就会马上遇到一个问题:
它出错时,你看得见吗?
用户不会说:
你的 Agent 在 step_2 调用 searchPolicy 时发生 empty_result,然后降级逻辑没有正确写入 final answer。
用户只会说:
这个 AI 不靠谱。
如果你没有 trace、结构化日志和运行报告,排查就会变成猜谜。
所以今天讲 Agent 上线前必须补的一层:Agent 可观测性。
1. 痛点:只看最终答案,根本不知道 Agent 错在哪
普通接口出错,排查路径通常比较直:
请求进来
-> 业务逻辑
-> 数据库
-> 响应出去
Agent 不一样。
一次 Agent 运行可能经历:
用户输入
-> 创建计划
-> 执行 step_1
-> 调用工具 A
-> 工具超时
-> 重试
-> 工具成功
-> 执行 step_2
-> 工具 B 空结果
-> 降级跳过
-> 生成最终回答
中间每一步都可能影响最终答案。
如果你只记录最终输出:
final_answer = "建议联系人工客服。"
那你根本不知道这个答案是怎么来的。
它可能是合理降级。
也可能是工具失败后的兜底。
也可能是模型忽略了某个 observation。
所以 Agent 的可观测性不是锦上添花,而是上线前的基本条件。
2. 错误做法:只靠 console.log 和最终答案排查
很多初版 Agent 会这样写:
async function runAgent(userInput: string) {
console.log("agent started");
const plan = await createPlan(userInput);
console.log("plan created");
const result = await runPlan(plan);
console.log("agent finished");
return result.answer;
}
这在本地调试时能凑合,但一上并发就很难用。
它有几个问题:
- 不知道日志属于哪一次运行。
- 不知道每一步的输入输出。
- 不知道工具耗时和失败原因。
- 不知道重试、降级、暂停是否发生。
- 没法按工具、错误类型、用户反馈做统计。
最终结果就是:用户说“不靠谱”,你只能从一堆零散日志里翻。
3. 正确做法:每次运行生成 traceId
可观测性的第一步,是给每次 Agent 运行一个 traceId。
function createTraceId() {
return `trace_${Date.now()}_${crypto.randomUUID()}`;
}
它像一次运行的身份证。
后续所有事件都带上它:
type AgentEventBase = {
traceId: string;
timestamp: string;
eventType: string;
};
有了 traceId,你才能把同一次运行里的事件串起来:
- 用户问了什么。
- 创建了什么计划。
- 执行了哪些步骤。
- 调用了哪些工具。
- 哪一步失败。
- 是否重试。
- 是否降级。
- 最终回答是什么。
前端开发者应该很熟悉这个概念。
它就是 AI Agent 里的 requestId、correlationId、sessionId。
没有它,日志会散成一地碎片。
4. 正确做法:日志要结构化,不要只写字符串
先定义 Agent 的关键事件类型:
type AgentEventType =
| "agent_started"
| "plan_created"
| "step_started"
| "tool_started"
| "tool_succeeded"
| "tool_failed"
| "step_retried"
| "step_skipped"
| "step_paused"
| "step_fallback"
| "agent_finished";
type AgentEvent = AgentEventBase & {
eventType: AgentEventType;
stepId?: string;
toolName?: string;
status?: string;
durationMs?: number;
errorType?: string;
retryCount?: number;
inputSummary?: string;
outputSummary?: string;
};
再封装统一入口:
function now() {
return new Date().toISOString();
}
function logAgentEvent(
events: AgentEvent[],
event: Omit<AgentEvent, "timestamp">
) {
const nextEvent: AgentEvent = {
...event,
timestamp: now()
};
events.push(nextEvent);
return nextEvent;
}
后面记录事件时,不要散落写 console.log:
logAgentEvent(events, {
traceId,
eventType: "tool_failed",
stepId: "step_2",
toolName: "searchPolicy",
errorType: "empty_result",
retryCount: 0,
durationMs: 382
});
结构化日志有几个巨大好处:
- 可以按
traceId搜索。 - 可以按
errorType统计。 - 可以按
toolName聚合。 - 可以分析哪些工具最容易失败。
- 可以把线上反馈和执行过程关联起来。
这不是“日志写得漂亮”,而是让系统具备复盘能力。
5. 最小可观测事件清单
不用一开始就搭复杂监控系统。
学习阶段,先把事件记录到一个数组里:
const events: AgentEvent[] = [];
最小事件清单建议覆盖 5 类:
1. 生命周期:agent_started / agent_finished
2. 计划:plan_created
3. 步骤:step_started / step_retried / step_skipped / step_paused / step_fallback
4. 工具:tool_started / tool_succeeded / tool_failed
5. 结果:final answer summary
这份清单已经覆盖 Agent 的关键路径。
如果用户说答案不对,你至少能回答:
- Agent 有没有创建计划?
- 计划有几步?
- 哪一步开始执行?
- 哪个工具被调用?
- 工具成功还是失败?
- 有没有重试?
- 有没有跳过?
- 最终是怎么结束的?
这就是从“感觉不对”到“知道错在哪”的差别。
6. 把可观测性接进 Agent 执行器
下面是一个简化版执行器,展示 trace 和事件怎么接进去:
这里沿用前几篇里的 PlanStep、createPlan、act、observe 和 generateFinalAnswer,重点看可观测事件如何贯穿整个执行过程。
async function runObservableAgent(userInput: string) {
const traceId = createTraceId();
const events: AgentEvent[] = [];
logAgentEvent(events, {
traceId,
eventType: "agent_started",
inputSummary: maskSensitiveText(userInput)
});
const plan = createPlan(userInput);
logAgentEvent(events, {
traceId,
eventType: "plan_created",
outputSummary: `${plan.length} steps`
});
for (const step of plan) {
const start = performance.now();
logAgentEvent(events, {
traceId,
eventType: "step_started",
stepId: step.id,
toolName: step.toolName
});
logAgentEvent(events, {
traceId,
eventType: "tool_started",
stepId: step.id,
toolName: step.toolName,
inputSummary: JSON.stringify(step.args)
});
const result = await act(step);
const durationMs = Math.round(performance.now() - start);
if (result.ok) {
logAgentEvent(events, {
traceId,
eventType: "tool_succeeded",
stepId: step.id,
toolName: step.toolName,
durationMs,
outputSummary: JSON.stringify(result.data)
});
} else {
logAgentEvent(events, {
traceId,
eventType: "tool_failed",
stepId: step.id,
toolName: step.toolName,
durationMs,
errorType: result.errorType,
outputSummary: result.message
});
}
observe(step, result);
}
const finalAnswer = generateFinalAnswer(plan);
logAgentEvent(events, {
traceId,
eventType: "agent_finished",
status: finalAnswer.ok ? "success" : "failed",
outputSummary: finalAnswer.answer
});
return {
traceId,
answer: finalAnswer.answer,
plan,
events,
report: buildRunReport(traceId, plan, events, finalAnswer.answer)
};
}
这段代码的重点不是实现完整 Agent,而是展示一个原则:
Agent 每走一步,都要留下可追踪的事件。
7. 运行报告:给人看的摘要
事件日志是细节。
但人排查问题时,还需要摘要。
type AgentRunReport = {
traceId: string;
status: "success" | "failed";
totalSteps: number;
doneSteps: number;
failedSteps: number;
skippedSteps: number;
retryCount: number;
toolsUsed: string[];
totalDurationMs: number;
finalAnswer: string;
};
function buildRunReport(
traceId: string,
plan: PlanStep[],
events: AgentEvent[],
finalAnswer: string
): AgentRunReport {
const toolsUsed = Array.from(
new Set(events.map((event) => event.toolName).filter(Boolean))
) as string[];
const retryCount = events.filter(
(event) => event.eventType === "step_retried"
).length;
const totalDurationMs = events.reduce(
(sum, event) => sum + (event.durationMs ?? 0),
0
);
return {
traceId,
status: plan.some((step) => step.status === "failed")
? "failed"
: "success",
totalSteps: plan.length,
doneSteps: plan.filter((step) => step.status === "done").length,
failedSteps: plan.filter((step) => step.status === "failed").length,
skippedSteps: plan.filter((step) => step.status === "skipped").length,
retryCount,
toolsUsed,
totalDurationMs,
finalAnswer
};
}
事件日志像详细流水账。
运行报告像结案摘要。
两者都需要。
8. 前端仪表盘应该展示什么?
Agent 仪表盘不需要一开始做得很重。
先展示 5 类信息就够用:
- 总运行次数、成功率、失败率。
- 平均耗时、P95 耗时。
- 工具调用次数、工具失败率。
- 重试、降级、暂停、跳过次数。
- 最近失败 trace 列表。
一个简化的前端组件可以这样写:
function AgentRunReportCard({ report }: { report: AgentRunReport }) {
return (
<section>
<h3>Agent Run</h3>
<dl>
<dt>Trace</dt>
<dd>{report.traceId}</dd>
<dt>Status</dt>
<dd>{report.status}</dd>
<dt>Steps</dt>
<dd>
{report.doneSteps}/{report.totalSteps}
</dd>
<dt>Retries</dt>
<dd>{report.retryCount}</dd>
<dt>Duration</dt>
<dd>{report.totalDurationMs}ms</dd>
</dl>
</section>
);
}
这类 UI 不一定给最终用户看,但一定适合开发环境、运营后台和问题排查后台。
9. 可观测性不是无限记录隐私
这里要踩一脚刹车。
可观测性不是“什么都记下来”。
Agent 日志里可能包含:
- 手机号。
- 邮箱。
- 订单号。
- 姓名。
- 地址。
- 公司内部资料。
如果为了排查方便,把这些信息无限期、无权限控制地记录下来,系统本身就会变成风险。
至少要有脱敏意识:
function maskSensitiveText(text: string) {
return text
.replace(/\b1[3-9]\d{9}\b/g, "[PHONE]")
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}\b/gi, "[EMAIL]");
}
真实项目里还要考虑:
- 谁能看日志。
- 日志保留多久。
- 哪些字段不能存。
- 用户反馈和 trace 如何关联。
- 敏感字段是否需要哈希或脱敏。
可观测性是为了让系统更可信,不是为了收集更多用户隐私。
10. 生产环境避坑指南
1. 不要只记录最终答案
Agent 的问题往往出在中间步骤。
只记录 final answer,等于把最有价值的排查信息丢了。
2. traceId 必须贯穿前后端
前端请求、后端 Agent 运行、工具调用、用户反馈,都应该带同一个 traceId。
否则用户反馈很难关联到具体执行过程。
3. 参数要摘要化,不要全量裸存
工具参数可能包含敏感字段。
建议记录 inputSummary,必要时脱敏或哈希,而不是直接把完整参数塞进日志。
4. 指标要能指导优化
不要只看运行总数。
更应该看:
- 哪个工具失败率最高?
- 哪类错误最常见?
- 哪个步骤最耗时?
- 哪些任务最容易降级?
- 哪些 trace 被用户点踩?
这些指标才能指导下一步 Agent 优化。
5. 日志和报告要能回流评测集
高频失败 trace 不应该只停留在日志里。
应该把它们沉淀成测试用例或 Agent 评测集,防止下次再犯。
11. 常见误区
误区 1:有 console.log 就算有日志
不算。console.log 适合临时调试,不适合长期排查。工程日志应该结构化。
误区 2:只记录最终答案就够了
不够。Agent 的问题往往出在中间步骤,不记录过程就很难定位。
误区 3:日志越多越好
不是。日志要围绕关键事件和排查目标设计,还要考虑隐私和成本。
误区 4:可观测性是上线后再补的
最好不要。Agent 一旦开始真实使用,没有可观测性会让每次问题排查都变成补历史账。
12. 给前端开发者的落地清单
如果你准备让 Agent 进入真实项目,可以先按这份清单补可观测性:
- 每次运行生成
traceId。 - 所有事件都带
traceId。 - 记录
agent_started和agent_finished。 - 记录计划创建结果。
- 记录每一步开始和结束。
- 记录每次工具调用的工具名、参数摘要和耗时。
- 记录工具成功、失败、错误类型。
- 记录重试、跳过、暂停和降级。
- 生成运行报告。
- 对敏感信息做脱敏或最小化记录。
有了这套基础,你的 Agent 才不只是能跑,而是能被团队一起维护。
结语
Agent 上线前,一定要先装上仪表盘。
没有 trace,你串不起一次运行。
没有结构化事件,你定位不了中间步骤。
没有运行报告,你很难快速理解这次任务到底发生了什么。
可观测性不是高级功能。
它是 Agent 从 demo 走向产品的安全带。
一个真正可维护的 Agent,不只是能回答用户问题,还要能在出错时把自己的执行过程摊开,让团队看得见、查得到、改得动。