Agent 的输出具有不确定性:同一个输入,两次运行的路径可能不同。传统的“写几个单元测试就放心”在这里行不通。本文介绍如何给 Agent 建立 评估(Evaluation) 与 可观测性(Observability) 体系:怎样拆解评估维度、构建评测集、选择打分方式、记录 Trace,并顺带讨论成本与延迟的护栏。文中给出 Python 的回归评测与 Trace 记录代码、Java 的埋点示例,以及常见坑和检查清单。

一、背景:为什么 Agent 特别需要评估和观测
传统软件的行为大多是确定的:相同输入得到相同输出,单元测试通过就有较强的信心。Agent 有三点不同:
非确定性:模型本身有随机性,同一任务可能走不同路径,甚至有时成功有时失败;
多步累积:每一步都可能出错,错误会沿着链路传递,最终结果的失败往往要追溯到前面某一步;
外部依赖多:模型服务、工具接口、检索数据任何一个变化,都可能改变整体表现。
因此“改了一句提示词,感觉变好了”这种判断很不可靠。你需要两样东西:评估——用一批固定样本衡量改动前后的变化;可观测性——线上出问题时,能回放“它当时到底做了什么”。
二、评估什么
不要只看“最终答案对不对”,建议拆成三层:
结果层:任务是否完成?答案是否正确、完整?
过程层:工具选择是否合理?参数是否正确?有没有多余或重复的调用?
效率层:用了多少步、多少 token、多长时间、花了多少钱?
三层分开看的价值在于定位问题。比如最终答案正确,但走了 15 步(本来 3 步就够)——结果层合格,效率层不合格,说明有优化空间;再如最终答案错误,但过程层显示工具选择与参数都正确——问题可能出在检索数据或最后的总结环节。
| 层次 | 典型指标 | 发现的问题类型 |
|---|---|---|
| 结果层 | 任务完成率、答案正确率、格式合规率 | 最终不可用、答案错误 |
| 过程层 | 工具选择正确率、参数正确率、重复调用次数 | 选错工具、乱填参数、绕远路 |
| 效率层 | 平均步数、token 用量、端到端延迟、单次成本 | 太慢、太贵、容易超限 |
三、构建评测集
评测集是 Agent 开发中最有价值的资产之一。
从真实场景收集:日志里的真实用户请求,比你自己想象的更有代表性。
覆盖三类样本:典型场景、边界情况、已知的失败案例。
每条样本写明期望:可以是标准答案、必须调用的工具、或必须满足的条件。
小而精起步:30~50 条高质量样本,好过 1000 条随意拼凑的。
版本化管理:每次修改提示词或工具,都用同一批样本回归。
一条样本可以这样表示:
{
"id": "case-017",
"input": "帮我查一下明天杭州的天气,并告诉我要不要带伞",
"expect": {
"must_call": ["get_weather"],
"args_contain": {"city": "杭州"},
"answer_should": ["明确给出是否带伞的建议"]
}
}
样本的分类建议
| 类别 | 例子 | 目的 |
|---|---|---|
| 典型场景 | 查天气、查订单等高频请求 | 确保主路径不退化 |
| 边界输入 | 城市名拼错、缺少必填信息、超长输入 | 检验澄清与容错能力 |
| 工具失败 | 模拟工具超时、返回空结果、返回错误 | 检验恢复能力和是否会编造 |
| 不应调用 | 闲聊、与工具无关的问题 | 防止“见工具就调用” |
| 安全相关 | 越权请求、带注入文本的内容 | 检验权限与防御(见后续安全文章) |
| 历史失败 | 线上曾出现的真实失败案例 | 防止同一问题重复出现 |
四、几种打分方式
| 方式 | 适用 | 优点 | 缺点 |
|---|---|---|---|
| 规则/断言 | 格式、工具调用、字段值 | 稳定、便宜、可重复 | 难以评价开放式文本 |
| 参考答案对比 | 有标准答案的问答 | 直观 | 表述不同但正确的答案可能被误判 |
| LLM 作为评审 | 开放式质量评价 | 覆盖面广 | 存在偏差,需要用人工抽检校准 |
| 人工评审 | 关键场景、最终把关 | 最可靠 | 慢、贵 |
建议组合使用:能用规则断言的先用规则,开放式部分再用 LLM 评审,并定期人工抽查评审结果。
使用“LLM 作为评审”时有几个注意点:评审提示词要给出明确的评分标准和示例;让评审模型先写理由再给分;尽量与被评测的模型分开,避免自我偏好;对同一批样本,用人工标注的一小部分结果检验评审模型的一致性。
五、一个可用的回归评测脚本
因为输出不确定,同一条样本最好多次运行并统计通过率,而不是只看一次结果。下面的脚本包含:多次运行、规则断言、步数与 token 统计,以及与基线对比。
import json, statistics, time
from dataclasses import dataclass
@dataclass
class Trace:
steps: list # 每步:{"tool": str|None, "args": dict, "output": str, "tokens": int}
final_answer: str
total_tokens: int
elapsed_s: float
def check(trace: Trace, expect: dict) -> tuple[bool, list[str]]:
"""规则断言:返回 (是否通过, 失败原因列表)"""
reasons = []
called = [s["tool"] for s in trace.steps if s.get("tool")]
for t in expect.get("must_call", []):
if t not in called:
reasons.append(f"应调用 {t} 但未调用")
for t in expect.get("must_not_call", []):
if t in called:
reasons.append(f"不应调用 {t}")
for k, v in expect.get("args_contain", {}).items():
if not any(str(v) in json.dumps(s["args"], ensure_ascii=False)
for s in trace.steps if s.get("tool")):
reasons.append(f"参数中未包含 {k}={v}")
for kw in expect.get("answer_contains", []):
if kw not in trace.final_answer:
reasons.append(f"答案缺少关键词:{kw}")
if len(trace.steps) > expect.get("max_steps", 10):
reasons.append(f"步数 {len(trace.steps)} 超过上限")
return (not reasons), reasons
def evaluate(agent, cases: list[dict], runs: int = 3) -> list[dict]:
report = []
for case in cases:
results, steps, tokens, times, fail_reasons = [], [], [], [], []
for _ in range(runs):
t0 = time.time()
trace: Trace = agent.run(case["input"]) # 需返回上面的 Trace 结构
ok, reasons = check(trace, case["expect"])
results.append(ok)
steps.append(len(trace.steps))
tokens.append(trace.total_tokens)
times.append(time.time() - t0)
fail_reasons += reasons
report.append({
"id": case["id"],
"pass_rate": sum(results) / runs,
"avg_steps": statistics.mean(steps),
"avg_tokens": statistics.mean(tokens),
"p_max_latency_s": max(times),
"top_fail_reasons": sorted(set(fail_reasons))[:3],
})
return report
def compare(baseline: list[dict], current: list[dict], drop_tolerance: float = 0.1):
"""与上一版本对比,找出通过率明显下降的样本"""
base = {r["id"]: r for r in baseline}
regress = [r["id"] for r in current
if r["id"] in base and base[r["id"]]["pass_rate"] - r["pass_rate"] > drop_tolerance]
return regress # 若非空,阻止发布并人工查看
注意 runs=3 与 drop_tolerance=0.1 只是示例值。样本少、运行次数少时,通过率的波动本身就很大,不要把一两次的差异当成“显著变化”。

六、可观测性:记录 Trace
线上出了问题,你需要能回放“它当时到底做了什么”。建议至少记录:
每次任务的唯一 trace id;
每一步的 模型输入/输出、工具名、参数、返回值、耗时;
token 用量与费用;
错误、重试、超时、触发的人工确认;
用户反馈(点赞/点踩)关联到对应 trace。
注意对日志中的敏感信息脱敏,并设置保存期限。
Trace 的结构:一条任务、多个 Span
Trace(一次任务,trace_id = a1b2c3)
├─ Span 1 llm_call 模型决策 耗时 1.8s tokens 820
├─ Span 2 tool_call get_weather 耗时 0.4s 参数 {"city":"杭州"}
├─ Span 3 llm_call 模型总结 耗时 1.2s tokens 640
└─ 结果:success,总耗时 3.4s,总 token 1460
Python:一个轻量的 Trace 记录器
import json, time, uuid, contextlib
from contextvars import ContextVar
_current_trace: ContextVar[dict | None] = ContextVar("trace", default=None)
SENSITIVE_KEYS = {"api_key", "password", "token", "authorization"}
def redact(obj):
"""对常见敏感字段脱敏,避免凭证进入日志"""
if isinstance(obj, dict):
return {k: ("***" if k.lower() in SENSITIVE_KEYS else redact(v)) for k, v in obj.items()}
if isinstance(obj, list):
return [redact(x) for x in obj]
return obj
@contextlib.contextmanager
def trace_task(user_id: str):
trace = {"trace_id": uuid.uuid4().hex, "user_id": user_id,
"start": time.time(), "spans": [], "status": "running"}
token = _current_trace.set(trace)
try:
yield trace
trace["status"] = "success"
except Exception as e:
trace["status"] = "error"
trace["error"] = f"{type(e).__name__}: {e}"
raise
finally:
trace["elapsed_s"] = round(time.time() - trace["start"], 3)
print(json.dumps(redact(trace), ensure_ascii=False)) # 生产中写入日志系统/存储
_current_trace.reset(token)
@contextlib.contextmanager
def span(kind: str, name: str, **attrs):
t0 = time.time()
record = {"kind": kind, "name": name, "attrs": attrs}
try:
yield record
except Exception as e:
record["error"] = f"{type(e).__name__}: {e}"
raise
finally:
record["elapsed_s"] = round(time.time() - t0, 3)
trace = _current_trace.get()
if trace is not None:
trace["spans"].append(record)
# 用法示例
with trace_task(user_id="u1"):
with span("tool_call", "get_weather", args={"city": "杭州"}) as s:
s["output"] = get_weather(city="杭州")
Java:用 Micrometer 记录指标
在 Spring 项目里,可以用 Micrometer 记录调用次数、耗时和 token 用量,接入已有的监控系统:
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
@Component
public class AgentMetrics {
private final MeterRegistry registry;
public AgentMetrics(MeterRegistry registry) {
this.registry = registry;
}
/** 记录一次工具调用:按工具名和结果打标签,便于按维度聚合 */
public <T> T timeTool(String toolName, java.util.concurrent.Callable<T> call) throws Exception {
Timer.Sample sample = Timer.start(registry);
String outcome = "success";
try {
return call.call();
} catch (Exception e) {
outcome = "error";
throw e;
} finally {
sample.stop(Timer.builder("agent.tool.duration")
.tag("tool", toolName)
.tag("outcome", outcome)
.register(registry));
}
}
/** 记录 token 用量(区分输入与输出) */
public void recordTokens(String model, long inputTokens, long outputTokens) {
registry.counter("agent.llm.tokens", "model", model, "type", "input").increment(inputTokens);
registry.counter("agent.llm.tokens", "model", model, "type", "output").increment(outputTokens);
}
}
需要提醒:指标的标签(tag)取值要有限,不要把 user id、trace id 这类高基数值作为标签,否则会让监控系统的存储膨胀。这类信息应放在日志或 Trace 系统中。
七、成本与延迟控制
设置 最大步数、最大 token、最大耗时,超限时优雅终止。
简单任务用小模型,复杂推理才用大模型(路由策略)。
缓存重复的检索和工具结果。
压缩上下文:摘要历史、裁剪工具输出。
对相互独立的工具调用做并行。
按用户/租户设置额度和限流。
一个简单的预算护栏:
class Budget: def __init__(self, max_steps=10, max_tokens=30_000, max_seconds=60): self.max_steps, self.max_tokens, self.max_seconds = max_steps, max_tokens, max_seconds self.steps = self.tokens = 0 self.start = time.time() def charge(self, tokens: int): self.steps += 1 self.tokens += tokens def exceeded(self) -> str | None: if self.steps >= self.max_steps: return "步数已达上限" if self.tokens >= self.max_tokens: return "token 已达上限" if time.time() - self.start >= self.max_seconds: return "耗时已达上限" return None # 未超限 # 在 Agent 循环中:每步结束后 charge,开始下一步前检查 exceeded(), # 超限则终止并向用户说明“已完成到哪一步、为什么停止”,而不是悄悄返回残缺结果。
八、上线后的闭环
线上监控关键指标:成功率、平均步数、费用、延迟、错误率。
定期抽查失败与低分 trace。
把新发现的失败案例补进评测集。
改动后先跑回归,再灰度发布。
没有评测集,所有的“优化”都只是感觉;没有 trace,所有的“排查”都只是猜测。

一个排查故事:从一条 Trace 找到根因
假设用户反馈:“问天气,Agent 总说查不到。”你拿到对应的 trace:
Span 1:模型决定调用
get_weather,参数{"city": "杭州市"};Span 2:工具返回“城市不存在”;
Span 3:模型没有重试,直接回答“抱歉,暂时查不到”。
定位很清晰:工具描述里写的是“城市中文名,不要带‘市’字”,但模型传入了“杭州市”,而错误信息不够可行动,没有提示“请去掉‘市’”。修复方案是:(1)在工具内部兼容带“市”的输入;(2)让错误信息包含建议;(3)把这条样本加入评测集,防止回归。没有 trace,这类问题只能靠猜。
九、把评估接入发布流程:从“手动跑”到“自动挡”
评测脚本只有被纳入日常流程才会发挥价值,否则很快会被遗忘。一个务实的落地方式是分三个阶段推进:
本地快速回归:开发者改动提示词或工具后,在本地跑一小批核心样本(例如 10~20 条),几分钟内得到反馈,用来做“冒烟测试”;
提交前完整回归:在持续集成(CI)中跑全量评测集,与基线对比。若有样本的通过率明显下降,或平均成本、步数显著上升,就阻止合并并要求说明原因;
灰度发布与线上对比:新版本先对一小部分流量开放,对比新旧版本的成功率、延迟、费用和用户反馈,确认没有退化再逐步放量。
这里有几个需要事先约定的细节:
基线是什么:通常是当前线上版本在同一评测集上的结果,而不是某次“最好”的结果;
什么算显著变化:样本少时波动大,建议设置“容忍区间”,并对落在区间边缘的变化做人工复核;
成本与延迟也是发布门槛:一次改动让通过率提升了几个百分点,却让单次成本翻倍,是否值得要由业务来权衡,而不是默认接受;
评测集本身也要维护:当产品能力或工具变化后,过时的期望会产生“假失败”,需要定期清理和更新。
还要提醒的是,离线评测分数高,并不保证线上表现好:线上用户的输入分布会变化,评测集总是滞后于现实。所以线上的 trace 与反馈回流才是评测集保持“新鲜”的关键。
十、常见坑与解决办法
| 坑 | 表现 | 解决办法 |
|---|---|---|
| 只看最终答案 | 答案对但过程浪费,成本居高不下 | 同时评估过程层与效率层 |
| 样本只跑一次 | 结果时好时坏,无法判断改动是否有效 | 每条样本多次运行,统计通过率 |
| 评测集过于“干净” | 上线后遇到大量评测集里没有的情况 | 从真实日志持续补充样本,特别是失败案例 |
| LLM 评审不可信 | 打分随措辞漂移,偏爱冗长回答 | 给明确评分标准,并用人工标注样本校准 |
| 日志泄露敏感信息 | 密钥、个人信息出现在日志里 | 统一的脱敏函数,设置保存期限与访问权限 |
| 指标标签基数过高 | 监控系统变慢、成本激增 | 标签只用有限取值,高基数信息放日志 |
| 没有预算护栏 | 异常循环导致一夜烧掉大量费用 | 步数、token、耗时、租户额度多重限制 |
十一、评估与可观测性检查清单
☐ 有 30~50 条以上的评测样本,覆盖典型、边界、失败、不应调用四类。
☐ 每条样本有可程序判断的期望,开放性问题有评分标准。
☐ 每条样本多次运行,报告通过率而不是单次结果。
☐ 每次修改提示词、工具、模型版本都跑回归并与基线比较。
☐ 每个任务有 trace id,记录每步输入输出、耗时、token、错误。
☐ 日志已脱敏,有保存期限和访问控制。
☐ 有步数、token、耗时、租户额度的上限。
☐ 线上失败案例会定期回流到评测集。
十二、常见问题(FAQ)
Q1:评测集需要多大才够?没有固定数字。起步阶段 30~50 条覆盖面好的样本就能发现大量问题。随着产品成熟,再按失败案例和新功能持续扩充,重点是“覆盖面”和“贴近真实”,而不是数量本身。
Q2:非确定性这么强,怎么判断一次改动是否真的更好?多次运行取通过率,并尽量用同一批样本对比。如果差异很小且样本量不大,就不要下结论;需要时扩大样本或增加运行次数。也可以把关键指标的变化结合人工抽查一起判断。
Q3:可以把用户的完整对话都记进日志吗?要谨慎。完整对话可能含个人隐私或商业机密。建议明确记录范围,对敏感字段脱敏,设置保存期限与访问权限,并遵守所在地区的合规要求。必要时允许用户选择不被记录。
Q4:应该先做评估还是先做可观测性?两者互补,但如果只能先做一个:上线前先做最小的评测集和回归脚本;只要有真实用户使用,就尽快补上 trace。最低成本的做法是先把每步的输入输出、耗时与 token 写成结构化日志。
十三、小结与下一步
把 Agent 当作一个需要持续运营的系统:有评测集做回归,有 trace 做排查,有预算做护栏。这三件事做好,Agent 才能从 Demo 走向生产。
建议的下一步:
从线上日志或自己的使用中挑出 30 条任务,写成带期望的评测样本;
用上面的脚本跑一遍基线,记录通过率、平均步数与 token;
给 Agent 循环加上 trace 记录与预算护栏;
每次修改后对比基线,把新发现的失败案例补进评测集,形成持续改进的闭环。


2026-09-30鱼鱼