智能体面试准备(二十):可观测性实战——Trace 建模、OpenTelemetry、成本归因与故障定位
上一篇《RAG 评估体系》解决的是"离线怎么度量质量",这一篇解决"线上正在发生什么"。这是 B 系列第二十篇,也是"智能体质量工程"这条线的收官。可观测性(Observability)在传统微服务里已经是成熟话题,但智能体带来了三个全新挑战:执行路径非确定、单次请求内嵌套多层 LLM 调用、成本按 token 实时计费。一个 Agent 请求失败了,是模型规划错了、工具超时了、还是上下文被截断了?没有 trace 就只能靠猜。本文按"为什么 Agent 特别需要可观测性 → Trace/Span 数据模型 → OpenTelemetry GenAI 语义约定 → 手写一个可用的 tracer → 成本与延迟归因 → 故障定位与告警 → 落地选型"展开,结尾给面试速答和高频追问清单。
一、Agent 的可观测性难在哪
1.1 和传统微服务的四点本质差异
维度 传统微服务 LLM Agent ───────────────────────────────────────────────────────── 执行路径 确定(代码写死) 非确定(模型每次决策可能不同) 失败形态 抛异常、超时、5xx "成功返回但内容是错的"(最可怕) 成本模型 按 CPU/内存/时长 按 input/output token 实时计费 调试单元 一次函数调用 一次 LLM 调用 = prompt + 采样参数 + 输出 重放 输入相同则输出相同 temperature>0 时无法精确重放第二行是关键:Agent 最危险的故障是"HTTP 200 但答案错了"。传统监控盯的错误率、延迟、饱和度这套黄金指标,对这种故障完全失明。所以 Agent 可观测性必须同时覆盖"系统健康"和"输出质量"两条线。
1.2 一次典型 Agent 请求的复杂度
用户: "帮我查一下上季度华东区销售额,跟去年同期比一下,做成表格" ├─ [LLM] 规划: 拆成 3 步 1.2s 1.8k tok ¥0.018 ├─ [Tool] sql_query(上季度华东) 0.4s ¥0 ├─ [Tool] sql_query(去年同期华东) 0.5s ¥0 ├─ [LLM] 反思: 数据口径不一致,需要重查 0.9s 3.2k tok ¥0.032 ├─ [Tool] sql_query(统一口径重查) 0.6s ¥0 ├─ [LLM] 生成表格 2.1s 4.5k tok ¥0.045 └─ 总计 5.7s 9.5k tok ¥0.095 问题来了: - 如果总延迟 P95 突然从 5.7s 涨到 15s,是哪一步? - 如果单请求成本从 ¥0.095 涨到 ¥0.5,是谁在烧钱? - 如果"反思重查"这一步开始频繁出现,说明什么退化了? 没有 trace,这三个问题一个都答不了。二、Trace / Span 数据模型
2.1 基本概念
沿用 OpenTelemetry 的模型,但要为 Agent 场景做扩展。
Trace = 一次完整的用户请求,全局唯一 trace_id │ └── Span = 一个可计时的操作单元,有 span_id 和 parent_span_id │ └── 通过 parent 关系构成一棵树 Agent 场景的 Span 类型(这套分类是重点): AGENT 整个 agent 的一次运行(根 span) CHAIN 一个逻辑步骤/子链 LLM 一次模型调用 ← 记 token、成本、采样参数 TOOL 一次工具调用 ← 记入参、出参、是否超时 RETRIEVER 一次检索 ← 记 query、召回数、topk 分数 RERANKER 一次重排 EMBEDDING 一次向量化 GUARDRAIL 一次安全/格式校验2.2 树形结构示意
Trace: 7f3a9c... (总 5.7s, ¥0.095) │ └─ AGENT sales_report_agent [5.70s] ├─ LLM plan [1.20s] in=1200 out=600 ├─ CHAIN step_1_fetch [0.90s] │ └─ TOOL sql_query [0.40s] rows=128 ├─ CHAIN step_2_fetch [1.10s] │ └─ TOOL sql_query [0.50s] rows=131 ├─ LLM reflect [0.90s] in=2800 out=400 ← 异常步骤 ├─ CHAIN step_3_refetch [0.70s] │ └─ TOOL sql_query [0.60s] rows=259 └─ LLM render_table [2.10s] in=4100 out=4002.3 每类 Span 必须记录的字段
这张表是面试的硬货,能背下来说明真做过。
| Span 类型 | 必记字段 |
|---|---|
| 通用 | trace_id, span_id, parent_id, name, kind, start_ts, end_ts, status, error |
| LLM | model, provider, prompt(可脱敏/哈希), completion, input_tokens, output_tokens, cached_tokens, temperature, top_p, max_tokens, stop_reason, cost, ttft(首 token 延迟), retry_count |
| TOOL | tool_name, arguments, result(截断), is_error, latency, timeout_flag |
| RETRIEVER | query, top_k, num_returned, score_top1, score_mean, doc_ids, index_version |
| AGENT | user_id, session_id, agent_version, total_steps,终止原因(完成/超步数/超时/异常) |
三个容易漏的字段,提到会加分:
1.ttft(time to first token):流式场景用户感知的延迟是 ttft 而非总时长,必须单独记。
2.cached_tokens:prompt caching 命中数直接影响成本,不记就没法算真实节省。
3.stop_reason:length意味着被 max_tokens 截断,这是静默故障的重要信号——输出被砍断但 HTTP 依然 200。
三、OpenTelemetry GenAI 语义约定
不要自己发明属性名,OTel 已经有 GenAI 的语义约定(semantic conventions),用标准名才能被 Langfuse、Phoenix、Datadog 等后端自动识别。
核心属性(gen_ai.* 命名空间) ──────────────────────────────────────────── gen_ai.system 提供方,如 openai / anthropic gen_ai.operation.name chat / text_completion / embeddings gen_ai.request.model 请求的模型名 gen_ai.response.model 实际返回的模型名(可能有别名映射) gen_ai.request.temperature 采样温度 gen_ai.request.max_tokens 最大输出 gen_ai.usage.input_tokens 输入 token gen_ai.usage.output_tokens 输出 token gen_ai.response.finish_reasons 结束原因列表 gen_ai.conversation.id 会话 id Agent/Tool 扩展 ──────────────────────────────────────────── gen_ai.agent.name / gen_ai.agent.id gen_ai.tool.name / gen_ai.tool.call.id / gen_ai.tool.type用 OTel SDK 埋点的样子:
from opentelemetry import trace from opentelemetry.trace import Status, StatusCode tracer = trace.get_tracer("my.agent") def call_llm(messages, model="gpt-4o-mini", temperature=0.2): with tracer.start_as_current_span( f"chat {model}", attributes={ "gen_ai.system": "openai", "gen_ai.operation.name": "chat", "gen_ai.request.model": model, "gen_ai.request.temperature": temperature, }, ) as span: try: resp = client.chat.completions.create( model=model, messages=messages, temperature=temperature ) u = resp.usage span.set_attribute("gen_ai.usage.input_tokens", u.prompt_tokens) span.set_attribute("gen_ai.usage.output_tokens", u.completion_tokens) span.set_attribute("gen_ai.response.model", resp.model) span.set_attribute( "gen_ai.response.finish_reasons", [c.finish_reason for c in resp.choices], ) span.set_status(Status(StatusCode.OK)) return resp except Exception as e: span.record_exception(e) span.set_status(Status(StatusCode.ERROR, str(e))) raise为什么坚持用标准约定:一是换后端不用改埋点代码,二是社区的自动 instrumentation(如opentelemetry-instrumentation-openai)开箱即用,三是 dashboard 模板可以直接复用。自定义属性名的团队后来都会为迁移付出代价。
四、手写一个够用的 Tracer
不引入重型依赖也能做出可用的追踪。下面这份代码可以直接放进项目。
import time, uuid, json, threading, contextvars from dataclasses import dataclass, field, asdict from typing import Any, Optional _current_span = contextvars.ContextVar("current_span", default=None) @dataclass class Span: name: str kind: str # AGENT/CHAIN/LLM/TOOL/RETRIEVER/... trace_id: str span_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16]) parent_id: Optional[str] = None start_ts: float = field(default_factory=time.time) end_ts: Optional[float] = None status: str = "OK" error: Optional[str] = None attrs: dict[str, Any] = field(default_factory=dict) events: list[dict] = field(default_factory=list) @property def duration_ms(self) -> float: return round(((self.end_ts or time.time()) - self.start_ts) * 1000, 2) class Tracer: def __init__(self, exporter=None): self.exporter = exporter or (lambda spans: None) self._buf: list[Span] = [] self._lock = threading.Lock() def span(self, name: str, kind: str = "CHAIN", **attrs): return _SpanCtx(self, name, kind, attrs) def _finish(self, sp: Span): with self._lock: self._buf.append(sp) def flush(self): with self._lock: batch, self._buf = self._buf, [] if batch: self.exporter(batch) class _SpanCtx: def __init__(self, tracer, name, kind, attrs): self.tracer, self.name, self.kind, self.attrs = tracer, name, kind, attrs self.sp = None self.token = None def __enter__(self) -> Span: parent = _current_span.get() self.sp = Span( name=self.name, kind=self.kind, trace_id=parent.trace_id if parent else uuid.uuid4().hex, parent_id=parent.span_id if parent else None, attrs=dict(self.attrs), ) self.token = _current_span.set(self.sp) return self.sp def __exit__(self, exc_type, exc, tb): self.sp.end_ts = time.time() if exc: self.sp.status = "ERROR" self.sp.error = f"{exc_type.__name__}: {exc}" _current_span.reset(self.token) self.tracer._finish(self.sp) return False # 不吞异常用contextvars而不是全局变量或 threading.local,是因为它在 asyncio 协程里也能正确传递父子关系——这是 Agent 场景(大量并发 IO)的必需品。这个细节面试提到会加分。
4.1 配套的成本计算与埋点装饰器
# 单位:元 / 1K token(示例价格,实际以账单为准) PRICING = { "gpt-4o": {"in": 0.018, "out": 0.072, "cached_in": 0.009}, "gpt-4o-mini": {"in": 0.001, "out": 0.004, "cached_in": 0.0005}, "deepseek-chat": {"in": 0.001, "out": 0.002, "cached_in": 0.0001}, } def calc_cost(model: str, in_tok: int, out_tok: int, cached_tok: int = 0) -> float: p = PRICING.get(model) if not p: return 0.0 fresh_in = max(in_tok - cached_tok, 0) return round( (fresh_in * p["in"] + cached_tok * p.get("cached_in", p["in"]) + out_tok * p["out"]) / 1000, 6, ) def traced_llm(tracer: Tracer, model: str): """装饰 LLM 调用,自动记录 token、成本、ttft。""" def deco(fn): def wrapper(*args, **kwargs): with tracer.span(f"chat {model}", kind="LLM", **{"gen_ai.request.model": model}) as sp: t0 = time.time() resp = fn(*args, **kwargs) u = resp.usage cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0 sp.attrs.update({ "gen_ai.usage.input_tokens": u.prompt_tokens, "gen_ai.usage.output_tokens": u.completion_tokens, "gen_ai.usage.cached_tokens": cached, "gen_ai.response.finish_reasons": [c.finish_reason for c in resp.choices], "cost_cny": calc_cost(model, u.prompt_tokens, u.completion_tokens, cached), "ttft_ms": round((time.time() - t0) * 1000, 2), }) # 静默故障探针:被 max_tokens 截断 if any(c.finish_reason == "length" for c in resp.choices): sp.events.append({"name": "truncated_output", "ts": time.time()}) sp.status = "DEGRADED" return resp return wrapper return deco def traced_tool(tracer: Tracer): def deco(fn): def wrapper(*args, **kwargs): with tracer.span(f"tool {fn.__name__}", kind="TOOL", **{"gen_ai.tool.name": fn.__name__}) as sp: sp.attrs["arguments"] = json.dumps(kwargs, ensure_ascii=False)[:2000] out = fn(*args, **kwargs) sp.attrs["result_preview"] = str(out)[:1000] return out return wrapper return deco4.2 敏感数据处理
prompt 里很可能有用户隐私。三种处理策略按合规要求选:
import re, hashlib PII_PATTERNS = [ (re.compile(r"1[3-9]\d{9}"), "<PHONE>"), (re.compile(r"[\w.\-]+@[\w\-]+\.\w+"), "<EMAIL>"), (re.compile(r"\d{17}[\dXx]"), "<IDCARD>"), (re.compile(r"\d{16,19}"), "<BANKCARD>"), ] def sanitize(text: str, mode: str = "mask") -> str: """mode: mask(脱敏保留结构) / hash(只留指纹用于去重) / drop(完全不记)""" if mode == "drop": return "" if mode == "hash": return "sha256:" + hashlib.sha256(text.encode()).hexdigest()[:16] for pat, repl in PII_PATTERNS: text = pat.sub(repl, text) return text[:8000] # 顺便截断,避免 trace 存储爆炸生产环境的常见配置:开发/测试环境记全量 prompt,生产环境默认 mask,金融医疗类业务用 hash 或 drop 并单独申请审计权限查看。
五、成本与延迟归因
5.1 成本归因的三个维度
按会话 哪些 session 最烧钱?→ 找出异常长会话、死循环 按步骤 哪个 step 占成本最大?→ 通常是"带全量上下文的最后一次生成" 按工具 哪个工具触发的后续 LLM 调用最贵?→ 返回内容过长的工具是元凶from collections import defaultdict def cost_attribution(spans: list[Span]) -> dict: by_trace, by_name, by_model = defaultdict(float), defaultdict(float), defaultdict(float) tok_in = tok_out = cached = 0 for s in spans: if s.kind != "LLM": continue c = s.attrs.get("cost_cny", 0.0) by_trace[s.trace_id] += c by_name[s.name] += c by_model[s.attrs.get("gen_ai.request.model", "unknown")] += c tok_in += s.attrs.get("gen_ai.usage.input_tokens", 0) tok_out += s.attrs.get("gen_ai.usage.output_tokens", 0) cached += s.attrs.get("gen_ai.usage.cached_tokens", 0) total = sum(by_trace.values()) n = max(len(by_trace), 1) return { "total_cost": round(total, 4), "avg_cost_per_trace": round(total / n, 6), "p95_cost_per_trace": round(sorted(by_trace.values())[int(n * 0.95) - 1], 6) if n > 1 else 0, "cache_hit_rate": round(cached / tok_in, 4) if tok_in else 0, "in_out_ratio": round(tok_in / tok_out, 2) if tok_out else 0, "top_steps": sorted(by_name.items(), key=lambda x: -x[1])[:5], "by_model": dict(by_model), }两个要重点盯的派生指标:
in_out_ratio(输入输出比)。Agent 场景这个值通常在 10:1 到 50:1,因为每轮都要带完整历史。如果它持续攀升,说明上下文在无节制膨胀,这是成本失控最常见的原因,解法是历史摘要、工具结果截断、或滑动窗口。
cache_hit_rate(prompt 缓存命中率)。system prompt 和工具定义是每轮都重复的,命中缓存能省 50%~90% 的输入成本。命中率低通常是因为在 prompt 开头放了变化的内容(如时间戳、随机 id),把它们挪到末尾就能大幅提升命中率——这是一个投入产出比极高的优化。
5.2 延迟归因
总延迟 = Σ LLM 延迟 + Σ 工具延迟 + 编排开销 排查顺序: 1) 看 span 树里最宽的那条 —— 关键路径 2) LLM 慢:区分 ttft 慢(排队/prompt 太长)还是生成慢(输出太长) 3) 工具慢:是外部 API 慢,还是没做并发(本可以并行的串行了) 4) 编排开销大:框架本身的序列化/校验损耗,LangChain 深嵌套时明显def latency_breakdown(spans: list[Span], trace_id: str) -> dict: ss = [s for s in spans if s.trace_id == trace_id] root = next((s for s in ss if s.parent_id is None), None) if not root: return {} llm = sum(s.duration_ms for s in ss if s.kind == "LLM") tool = sum(s.duration_ms for s in ss if s.kind == "TOOL") retr = sum(s.duration_ms for s in ss if s.kind == "RETRIEVER") total = root.duration_ms return { "total_ms": total, "llm_ms": llm, "tool_ms": tool, "retriever_ms": retr, "orchestration_ms": round(total - llm - tool - retr, 2), # 可能为负=有并行 "slowest_span": max(ss, key=lambda s: s.duration_ms).name, "parallel_detected": (llm + tool + retr) > total * 1.05, }orchestration_ms为负是个有用的信号——说明确实存在并行执行。反过来,如果它是个很大的正数,说明框架开销异常,值得深挖。
六、故障定位与告警
6.1 Agent 特有的故障模式
故障模式 信号 处置 ────────────────────────────────────────────────────────────── 死循环/来回横跳 同名 tool 连续调用 >3 次 硬性步数上限 + 检测重复 或 step 数触顶 上下文膨胀 in_tokens 随 step 单调递增 历史摘要 + 工具结果截断 且超过阈值 静默截断 finish_reason == "length" 提高 max_tokens 或分段生成 工具幻觉 调用了不存在的 tool_name schema 校验 + 白名单拦截 参数幻觉 JSON 解析失败 / 缺必填字段 结构化输出约束 + 重试 降级未察觉 provider 自动降级到小模型 校验 response.model 字段 检索空转 retriever 返回 0 条但继续生成 低置信兜底(见 B19) 超时雪崩 tool 超时 → 重试 → 更慢 熔断 + 超时预算分配6.2 循环检测的实现
def detect_anomalies(spans: list[Span], trace_id: str) -> list[dict]: ss = sorted([s for s in spans if s.trace_id == trace_id], key=lambda s: s.start_ts) issues = [] # 1. 同一工具用相同参数重复调用 → 死循环 seen = {} for s in ss: if s.kind != "TOOL": continue key = (s.attrs.get("gen_ai.tool.name"), s.attrs.get("arguments")) seen[key] = seen.get(key, 0) + 1 if seen[key] >= 3: issues.append({"type": "tool_loop", "tool": key[0], "count": seen[key]}) # 2. 上下文单调膨胀 ins = [s.attrs.get("gen_ai.usage.input_tokens", 0) for s in ss if s.kind == "LLM"] if len(ins) >= 3 and all(b > a for a, b in zip(ins, ins[1:])) and ins[-1] > 3 * max(ins[0], 1): issues.append({"type": "context_explosion", "trend": ins}) # 3. 静默截断 for s in ss: if "length" in (s.attrs.get("gen_ai.response.finish_reasons") or []): issues.append({"type": "silent_truncation", "span": s.name}) # 4. 模型被悄悄降级 for s in ss: req, resp = s.attrs.get("gen_ai.request.model"), s.attrs.get("gen_ai.response.model") if req and resp and not str(resp).startswith(str(req)): issues.append({"type": "model_mismatch", "requested": req, "served": resp}) return issues6.3 告警指标设计
传统的 RED(Rate/Error/Duration)不够用,Agent 要加两组。
基础层 (RED) 请求量 QPS / 错误率 / P50-P95-P99 延迟 ───────────────────────────────────────────── 成本层 单请求平均成本、P95 成本、日累计成本 in_out_ratio、cache_hit_rate → 告警:单请求 P95 成本环比涨 50% ───────────────────────────────────────────── 质量层(Agent 特有,最重要) 任务完成率 agent 正常结束 / 总请求 平均步数 突增 = 规划退化 工具错误率 分工具统计 JSON 解析失败率 截断率 finish_reason=length 占比 兜底触发率 低置信走兜底的比例 → 告警:任务完成率跌破 90%、平均步数环比涨 30%平均步数是最灵敏的先行指标。模型侧任何退化(换版本、改 prompt、上下文变长导致遵循度下降)都会先反映为"要绕更多弯才能完成",此时完成率可能还没跌,但成本和延迟已经在涨了。这是一个很有说服力的面试点。
6.4 采样策略
全量存 trace 成本太高(prompt 和 completion 都是大文本)。分层采样:
import random def should_sample(trace_summary: dict) -> tuple[bool, str]: """返回 (是否采样, 采样原因)。异常必存,正常抽样。""" if trace_summary.get("status") == "ERROR": return True, "error" # 错误 100% 存 if trace_summary.get("cost", 0) > 1.0: return True, "high_cost" # 高成本 100% 存 if trace_summary.get("latency_ms", 0) > 10000: return True, "slow" # 慢请求 100% 存 if trace_summary.get("user_feedback") == "down": return True, "negative_feedback" # 点踩 100% 存 if trace_summary.get("steps", 0) > 8: return True, "many_steps" # 步数异常 100% 存 return (random.random() < 0.05), "baseline" # 其余 5% 基线采样原则:异常样本全存,正常样本抽样。基线采样是为了保留分布参照——只存异常会导致无法判断"这个现象是不是本来就常见"。
七、工具选型
| 方案 | 特点 | 适用 |
|---|---|---|
| Langfuse | 开源可自托管,LLM 场景功能最全(trace/评测/prompt 管理/数据集) | 首选,尤其数据不能出境时 |
| Arize Phoenix | 开源,OTel 原生,评测能力强 | 重视评测与 OTel 生态 |
| LangSmith | LangChain 官方,体验最顺 | 已深度使用 LangChain |
| Datadog / 阿里云 ARMS | 与现有 APM 打通,运维统一 | 已有成熟 APM 体系 |
| 自建(OTel + ClickHouse + Grafana) | 完全可控,成本最低(大规模时) | 量大、有平台团队 |
选型建议:先用 Langfuse 或 Phoenix 快速起步,埋点严格遵循 OTel GenAI 语义约定,等规模上来再迁自建。因为埋点符合标准,迁移只需换 exporter,几乎零改造成本。这个"标准先行"的思路比选哪个具体产品更值得在面试里强调。
一个最小可用的落地清单:
第 1 周 接入 tracer,覆盖 LLM/TOOL/RETRIEVER 三类 span 记全 token、成本、finish_reason 第 2 周 建 dashboard:完成率、平均步数、P95 延迟、单请求成本 配前三个告警 第 3 周 接入用户反馈(点赞点踩)打到 trace 上 异常 trace 全采样 + 5% 基线采样 第 4 周 把线上 badcase trace 一键导出成评测集(对接 B19 的回归集) 形成"线上发现 → 离线固化 → CI 拦截"的闭环最后这一步是精髓:可观测性的终点不是看板,而是闭环。线上抓到的 badcase 要能沉淀成回归用例,下次改动时自动拦截,否则同一个问题会反复发生。
八、面试速答
Q:Agent 的可观测性和传统微服务有什么不同?
A:四点本质差异。第一,执行路径非确定,模型每次决策可能走不同分支,无法像微服务那样靠代码推断链路。第二,失败形态不同,最危险的是"HTTP 200 但答案是错的",传统的错误率、延迟、饱和度这套黄金指标对这种故障完全失明。第三,成本按 token 实时计费,必须把成本做成一等公民指标而不是月底看账单。第四,调试单元变了,一次 LLM 调用要连 prompt、采样参数、输出一起记录才有意义,而且 temperature 大于 0 时无法精确重放。所以 Agent 可观测性要同时覆盖系统健康和输出质量两条线。
Q:一次 Agent 请求你会记录哪些数据?
A:按 span 类型分。通用字段是 trace_id、span_id、parent_id、name、kind、起止时间、状态。LLM span 记模型名、脱敏后的 prompt 和 completion、输入输出 token、缓存命中 token、温度等采样参数、finish_reason、成本、ttft、重试次数。Tool span 记工具名、入参、结果预览、是否超时。Retriever span 记 query、top_k、召回数、top1 分数、索引版本。三个最容易漏但很关键的字段是 ttft(流式场景用户感知的就是它)、cached_tokens(不记就算不出真实成本节省)、finish_reason(等于 length 说明被静默截断,是重要的隐性故障信号)。
Q:为什么要用 OpenTelemetry 的 GenAI 语义约定?
A:三个理由。一是可移植,用标准属性名(gen_ai.request.model、gen_ai.usage.input_tokens 这些)后,换 Langfuse、Phoenix、Datadog 都不用改埋点代码,只换 exporter。二是能直接吃到社区的自动 instrumentation,主流 SDK 都有现成的 instrumentor。三是 dashboard 模板可以复用。自定义命名的团队后面迁移时都要还债。我的实践是:可以先用轻量的自建 tracer,但属性命名必须从第一天就对齐 OTel 标准。
Q:Agent 成本失控最常见的原因是什么?怎么发现?
A:最常见是上下文无节制膨胀。Agent 每一步都把完整历史带上,步数越多输入 token 增长越快,而且是平方级的累积效应。发现手段是盯两个派生指标:一是 in_out_ratio(输入输出 token 比),Agent 场景正常在 10:1 到 50:1,持续攀升就是膨胀信号;二是 input_tokens 随 step 的单调递增趋势,可以直接写规则检测。解法是历史摘要、工具结果截断、滑动窗口。另一个被忽视的点是 prompt 缓存命中率——system prompt 和工具定义每轮都重复,命中缓存能省一大半输入成本,很多团队命中率低是因为在 prompt 开头放了时间戳或随机 id,挪到末尾就能大幅改善,这是投产比极高的优化。
Q:怎么发现 Agent 陷入死循环?
A:三层防护。第一层是硬性上限,最大步数和总超时预算必须有,这是兜底。第二层是重复检测,把"工具名加参数哈希"作为 key 计数,同一组合连续出现三次就判定为循环并中断。第三层是语义层面的检测,比如连续两次反思的内容相似度过高、或者规划输出在两个动作间来回横跳。在监控上,平均步数是最灵敏的先行指标——任何模型侧的退化都会先表现为"要绕更多弯",此时完成率可能还没跌但成本延迟已经在涨了。
Q:trace 数据量太大,怎么控制成本?
A:分层采样加字段治理。采样上,异常样本 100% 存——错误、高成本、慢请求、用户点踩、步数异常都必存;正常样本 5% 基线采样,这个基线不能省,否则只有异常数据会导致无法判断某现象是不是本来就常见。字段上,prompt 和 completion 是存储大头,生产环境默认做 PII 脱敏加长度截断(8K 字符),金融医疗类业务用哈希指纹或干脆不记原文,单独申请审计权限查看。存储上冷热分离,最近 7 天全字段在热存储,历史数据只保留结构化指标不留原文。
Q:如何用可观测性数据反哺模型质量?
A:做成闭环,这是可观测性的终点。具体路径是:线上 trace 中筛出 badcase(用户点踩、走了兜底、步数异常、JSON 解析失败),人工确认后一键导出成评测用例,加入离线回归集;回归集接入 CI,下次改 prompt 或换模型时自动跑,挂了就阻断发布。这样同一个问题不会反复发生。再进一步,高质量的成功 trace 可以沉淀为 few-shot 示例库或 SFT 数据。判断一个团队可观测性做得好不好,就看有没有这个闭环——只有看板没有闭环,本质上只是把问题可视化了,没有解决问题。
Q:Agent 有哪些"静默故障"?怎么监控?
A:最典型的四种。一是输出被 max_tokens 截断,finish_reason 等于 length 但 HTTP 依然 200,监控靠统计截断率。二是模型被 provider 悄悄降级,请求的是大模型实际服务的是小模型,靠校验 response.model 和 request.model 是否一致来发现。三是检索空转,retriever 返回 0 条但流程继续往下走生成,模型只能凭空编,靠检索结果数和 top1 分数阈值告警。四是工具返回了错误内容但没抛异常(比如 SQL 查出空表),需要在工具层做结果合理性校验。这类故障的共同特点是不抛异常、不报错,只能靠业务语义层面的探针主动发现。
九、高频追问清单
- 为什么用 contextvars 而不是 threading.local 来传递 span 上下文?
- 流式输出场景下,span 的结束时间应该记在什么时候?ttft 怎么准确测量?
- 多智能体协作时,trace 树应该怎么组织?子 agent 是子 span 还是独立 trace?
- 采样率 5% 的情况下,怎么保证 P99 延迟统计的准确性?
- prompt 缓存命中率低,除了前缀变化还有哪些原因?
- 如何做跨 provider 的成本对比?token 计数口径不同怎么归一化?
- trace 里存了脱敏后的 prompt,但用户要求删除个人数据,怎么做定向清除?
- 如果 Agent 用了流式加并行工具调用,延迟归因的口径该怎么定义?
- 怎么把线上 trace 自动转化成可复现的回归测试用例?非确定性怎么处理?
- OTel 的 Metrics 和 Traces 在 Agent 场景怎么分工?哪些指标该走 metrics?
- 如果一次 Agent 请求跨了多个服务(网关、编排、模型代理),trace 怎么串起来?
- 可观测性系统本身挂了怎么办?埋点会不会拖慢主链路?
至此 B 系列前二十篇完成了从 ReAct、记忆、MCP、多智能体、安全,到规划、Function Calling、RAG 评估、可观测性的完整覆盖。今天这两篇(B19 + B20)是一对:前者管"离线怎么度量质量",后者管"线上正在发生什么",合起来才是完整的智能体质量工程。配合 A 系列今天的《模型量化》和《评测体系》看,刚好构成"模型侧优化—模型侧评测—系统侧评测—系统侧观测"的完整闭环。下一阶段 B 系列会转向更工程化的主题:Agent 的部署架构、状态管理与容灾、以及大规模并发下的编排优化。