跳到主内容
快讯直播
AI智模界
教程

用 Laminar 捕获智能体静默失败:埋点、失败分类与告警

报错现象

先说清楚"静默失败"长什么样:它不报错,它只是悄悄给出一个错的结果。

一个典型的现场:合同摘要智能体,每天早上跑一遍,把 N 份 PDF 抽成结构化字段写进下游数据库。运维看板上,任务成功率 100%,错误数 0,P99 延迟正常。直到业务方找上来,说昨天有一批合同的金额字段是空的、还有几份把甲方乙方弄反了。

翻日志,最后一条长这样:

```

INFO agent.run finished task=contract_summary status=success duration=42.1s items=50

```

打开链路追踪,这条 trace 的状态是 OK,span 数量 1,没有 exception,没有 error 标记。

这就是静默失败:进程没崩、接口返回 200、退出码 0、监控全绿,但业务结果是错的或者空的。

常见的具体表现,可以对照排查:

  • 任务返回空数组、空字符串、"暂无数据",但状态码是成功;
  • 检索命中 0 条,模型没有说"查不到",而是凭参数记忆编了一段,格式还很规范;
  • 工具调用返回 [] 或 {"data": null},代码当成正常输入继续往下走;
  • 多步任务在第 2 步就自己"总结收尾",剩下 8 步直接跳过,输出看起来完整;
  • 重试 3 次,前 2 次失败,最后成功,但 trace 只留下最后一次,中间失败看不见;
  • 定时任务因为上游 token 过期,每天静默返回空列表,退出码 0;
  • 异常被 except Exception: return "" 吞掉,日志里只有一条 info。

影响范围:所有把"没有抛异常"当成"成功"的链路。 多步智能体、批处理、异步队列尤其严重,因为步骤越多,中间某一步空转被"顺滑地"带过去的概率越高。危害不在于出错,而在于发现得太晚——错数据已经进了下游报表和周会材料。

可能原因

按出现概率从高到低:

1. "成功"没有定义。 代码里只有 try/except 和 HTTP 状态码判断,没有对输出做任何业务断言。什么叫"这份合同摘要合格",从来没人写成可执行的检查。

2. 埋点只有一层。 整个 run 一个 span,工具调用、检索、解析、校验都没有子 span。结果就是:即使失败了,你也看不出是哪一步空转的。

3. 空结果被当成正常输入。 检索 0 命中、工具返回 []、上游返回 200 但 body 是空的,代码不判断,直接喂给模型。

4. 异常被吞。 except 后 return 一个默认值走 fallback,span 上不标 error,于是链路里一片祥和。

5. 输出只做"能解析"校验。 JSON 能 json.loads 就算过,不校验字段非空、条数是否一致、枚举是否合法、数字是否在合理区间。

6. 采样把失败样本采没了,或者 trace 根本没上报成功。 上报失败本身也是静默的。

7. 评测集只有 happy path。 没有空结果样本、没有工具报错样本、没有"这条该拒答"的样本,所以离线指标一直很好看。

8. 告警只看 error rate 和延迟。 没有业务侧指标(每任务产出条数、检索命中率、工具调用次数、字段填充率)。

逐条排查与解决

原因 1:没有可执行的"成功标准"

判断方法:把 check_success 这类函数在代码库里搜一遍。搜不到,基本就是它。

解决:把业务方口头的"合格"翻译成函数,返回 (bool, reason_code),而不是抛异常。reason_code 就是后面的失败分类。

```python

success_criteria.py

from typing import Any

def check_contract_summary(raw_text: str, doc: dict[str, Any]) -> tuple[bool, str]:

if not doc:

return False, "SILENT_EMPTY_RESULT"

if not doc.get("party_a") or not doc.get("party_b"):

return False, "MISSING_REQUIRED_FIELD"

if doc.get("party_a") == doc.get("party_b"):

return False, "CONTRADICTION"

amount = doc.get("amount")

if amount is None:

return False, "MISSING_REQUIRED_FIELD"

摘要必须能在原文里找到依据,防止模型凭空生成

if str(doc.get("contract_no", "")) and str(doc["contract_no"]) not in raw_text:

return False, "UNGROUNDED_OUTPUT"

return True, "OK"

```

关键点是最后那条 UNGROUNDED_OUTPUT:很多静默失败就是"格式完美但内容无依据",只有做原文比对才能抓到。

原因 2:埋点粒度太粗

判断方法:在 Laminar 控制台打开一条标记为成功的 trace,数 span 数量。如果一次多步任务只有 1 个根 span,就是它。

解决:用 Laminar 的 SDK 接入(Python / TypeScript 都有,装饰器名称和参数在不同版本略有差异,以官方文档当前版本为准),并且把工具调用、检索、解析这些关键步骤都包成子 span。

```python

bootstrap.py —— 进程启动时执行一次

import os

from lmnr import Laminar

Laminar.initialize(project_api_key=os.environ["LMNR_PROJECT_API_KEY"])

```

```python

tracing_utils.py

from opentelemetry import trace

from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("agent")

def call_tool(name: str, fn, *args, **kwargs):

with tracer.start_as_current_span(f"tool.{name}") as span:

span.set_attribute("tool.name", name)

try:

result = fn(*args, **kwargs)

except Exception as e:

span.set_status(Status(StatusCode.ERROR, type(e).__name__))

span.set_attribute("failure.class", "TOOL_EXCEPTION")

span.record_exception(e)

raise

count = len(result) if hasattr(result, "__len__") else 1

span.set_attribute("tool.result_count", count)

if count == 0:

不抛异常,但明确标记这是失败信号

span.set_attribute("failure.class", "EMPTY_TOOL_RESULT")

span.set_status(Status(StatusCode.ERROR, "empty_tool_result"))

return result

```

Laminar 是 OpenTelemetry 原生接入的,所以 set_attribute / set_status 这套写法在任何支持 OTel 的后端都通用,将来换后端不用重写埋点。

必填属性建议固定成一份清单:task.name、task.run_id、tool.name、tool.result_count、retrieval.hit_count、llm.finish_reason、failure.class。清单之外的可以自由加,清单内的一条都不能少。

原因 3:空结果被当成正常输入

判断方法:搜 return []、or []、if data: 这类写法;或者看 trace 里 retrieval.hit_count 的分布,如果大量为 0 而任务仍标记成功,就是它。

解决:工具层统一返回带状态的对象,而不是裸列表,让调用方无法"顺手"忽略空值。

```python

from dataclasses import dataclass

@dataclass

class ToolResult:

ok: bool

data: list

reason: str = "OK"

def safe_search(query: str) -> ToolResult:

docs = search_docs(query)

if not docs:

return ToolResult(ok=False, data=[], reason="RETRIEVAL_MISS")

return ToolResult(ok=True, data=docs)

```

上层拿到 ok=False 必须显式处理:要么降级成"未检索到相关资料"的回复,要么标记任务为 silent_fail 并转人工。不允许直接继续生成。

原因 4:异常被吞

判断方法:全局搜 except Exception,看每个分支是不是只打了 info 日志就 return。再看 trace 里有没有"明明有 fallback 但 span 状态是 OK"的情况。

解决:吞异常可以,但必须留痕。

```python

except Exception as e:

span = trace.get_current_span()

span.set_attribute("failure.class", "TOOL_ERROR_SWALLOWED")

span.set_attribute("failure.detail", str(e)[:500])

span.set_status(Status(StatusCode.ERROR, "swallowed"))

return fallback_value # 照旧降级,但链路里留下了证据

```

判断是否修好:随便造一个工具抛异常的场景跑一次,去 Laminar 里按 failure.class = TOOL_ERROR_SWALLOWED 过滤,能查到就对了。

原因 5:输出校验太弱

判断方法:把最近 100 条"成功"的输出捞出来,跑一遍字段非空率和数值合理性检查,看有多少不达标。

解决:用 pydantic 或 jsonschema 做结构校验,再叠加业务规则校验。

```python

from pydantic import BaseModel, field_validator

class ContractSummary(BaseModel):

contract_no: str

party_a: str

party_b: str

amount: float

@field_validator("party_a", "party_b", "contract_no")

@classmethod

def not_blank(cls, v: str) -> str:

if not v or not v.strip():

raise ValueError("EMPTY_FIELD")

return v

def validate(raw: dict) -> tuple[bool, str]:

try:

ContractSummary(**raw)

except Exception as e:

return False, "SCHEMA_INVALID"

return True, "OK"

```

注意:pydantic 校验失败抛出的异常,如果被外层 except 吞掉,就又变回静默失败了。所以校验函数统一返回 (bool, reason),不要靠异常传递。

原因 6:采样或上报问题

判断方法,分三层验证:

第一层,span 到底生成了没有——用 console exporter 打到本地看:

```python

from opentelemetry.sdk.trace import TracerProvider

from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter

provider = TracerProvider()

provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))

trace.set_tracer_provider(provider)

```

能打印出来,说明埋点没问题。

第二层,span 发出去了没有——起一个本地 OTel Collector 的 debug 模式收一下:

```yaml

otel-collector.yaml

receivers:

otlp:

protocols:

http:

endpoint: 0.0.0.0:4318

exporters:

debug:

verbosity: detailed

service:

pipelines:

traces:

receivers: [otlp]

exporters: [debug]

```

```bash

otelcol --config otel-collector.yaml

```

把应用的导出地址临时指向这个 collector,看有没有 span 打出来。

第三层,落到 Laminar 了没有——拿一条已知的 trace_id 去控制台搜。搜不到就是网络或鉴权问题,先查 API key 环境变量是否注入、出网是否放行(具体的 OTLP 接入地址和鉴权头以 Laminar 控制台给出的接入指引为准)。

采样策略上,建议:错误与 failure.class != OK 的 trace 全量上报,成功的按比例采。 否则出问题时最需要的样本恰好被采掉了。

原因 7:评测集缺负样本

判断方法:看评测集里有多少条"输入本身就该被拒答"或"检索必然为空"的样本。占比接近 0 就是它。

解决:在 Laminar 的评测模块里建数据集,至少覆盖五类:正常样本、空检索样本、工具报错样本、该拒答样本、长尾格式样本。每次线上抓到静默失败,24 小时内把那条输入脱敏后回流进数据集。评测跑不过就卡住合并,这条比任何文档都管用。

原因 8:告警只看错误率

判断方法:看现有告警规则里有没有任何一条是基于业务指标的。全是 error_rate 和 latency,就是它。

解决:把失败分类打成指标,按比率告警。

```python

metrics.py

from prometheus_client import Counter

agent_task = Counter(

"agent_task_total",

"agent task outcomes",

["task", "outcome"], # outcome: ok / hard_fail / silent_fail / skipped

)

```

```yaml

alerts.yaml

groups:

  • name: agent-silent-failure

rules:

  • alert: SilentFailureRateHigh

expr: |

sum by (task) (rate(agent_task_total{outcome="silent_fail"}[30m]))

/

clamp_min(

sum by (task) (rate(agent_task_total{outcome=~"ok|silent_fail"}[30m])),

1e-9

) > 0.02

for: 15m

labels:

severity: warning

annotations:

summary: "任务 {{ $labels.task }} 静默失败率超过 2%"

  • alert: ZeroToolCallTask

expr: |

sum by (task) (rate(agent_task_total{outcome="skipped"}[15m])) > 0

for: 5m

labels:

severity: critical

annotations:

summary: "任务 {{ $labels.task }} 出现未调用任何工具的完成记录"

```

第二条规则针对的是最阴的一类:智能体说"已完成",但一次工具都没调。这种单条就该立刻告警,不要等比率。

失败分类不用设计得太细,先固定七八个码就够用:SILENT_EMPTY_RESULT、EMPTY_TOOL_RESULT、RETRIEVAL_MISS、TOOL_ERROR_SWALLOWED、SCHEMA_INVALID、UNGROUNDED_OUTPUT、CONTRADICTION、PARTIAL_COMPLETION。新增分类要在 PR 描述里说明触发条件,不然半年后没人知道 PARTIAL_COMPLETION 到底指什么。

都不管用时的兜底方案

一、上报链路完全打不通。 应用侧先落本地 JSONL 兜底,一个 run 一行,包含 run_id、输入摘要、失败分类、关键指标。用 sidecar 或定时任务补传。宁可先落盘,也不要因为上报失败把业务拖垮。

```python

import json, pathlib, time

def dump_local(record: dict) -> None:

p = pathlib.Path("/var/log/agent-traces")

p.mkdir(parents=True, exist_ok=True)

f = p / f"{time.strftime('%Y%m%d%H')}.jsonl"

with f.open("a", encoding="utf-8") as fh:

fh.write(json.dumps(record, ensure_ascii=False) + "\n")

```

二、出网受限。 应用只往本机 OTel Collector 发,由 Collector 统一转发到 Laminar。应用侧不需要知道外网地址,换后端也只改 Collector 配置。

三、链路二分定位。 按"生成了吗 → 发出去了吗 → 落库了吗 → 查询条件对吗"四步走,每步都有确定的验证手段(console exporter、collector debug、按 trace_id 搜、放宽过滤条件)。不要跳步猜。

四、加一层人工抽检。 每天从"成功"的任务里随机抽 N 条,自动跑一遍成功标准校验,不一致就报警。这是最笨也最可靠的一道网,能兜住所有埋点漏掉的情况。

五、影子运行。 新版本上线前,让新老两条链路对同一批输入各跑一遍,diff 输出。差异率超过阈值就不放量。

如何预防再次发生

  • 把成功标准写进代码,并和业务方一起过一遍。 口头说的"合格"和代码里的 return True 之间,通常差着一整个静默失败。
  • 埋点规范文档化。 span 命名规则、必填属性清单、失败分类枚举,三样东西进仓库,改一次记一次。
  • 评测集必须有负样本。 没有"该拒答"和"必然为空"的样本,离线指标就只是自我安慰。
  • 采样策略反着来。 失败全采、成功抽样,且保证任何一条 trace 都能按 run_id 全量回捞。
  • 告警分两级。 单条致命组合(比如零工具调用 + 标记完成)立即告警;比率类指标给 15 分钟窗口,避免抖动误报。
  • 看板放"失败分类 Top5"。 只放成功率,等于鼓励大家把失败藏起来。
  • 每周复盘一次静默失败样本。 挑三五条,问一句"它为什么没被拦住",答案要么变成新的校验规则,要么变成新的评测样本。

静默失败不会自己消失,它只会从你眼前消失。把"成功"定义清楚、把每一步都埋上点、把失败分类打进告警,这三件事做完,绝大多数"看起来成功实则失败"的任务都会在当天暴露出来,而不是在季度复盘时被业务方发现。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。