报错现象
先说清楚"静默失败"长什么样:它不报错,它只是悄悄给出一个错的结果。
一个典型的现场:合同摘要智能体,每天早上跑一遍,把 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"。 只放成功率,等于鼓励大家把失败藏起来。
- 每周复盘一次静默失败样本。 挑三五条,问一句"它为什么没被拦住",答案要么变成新的校验规则,要么变成新的评测样本。
静默失败不会自己消失,它只会从你眼前消失。把"成功"定义清楚、把每一步都埋上点、把失败分类打进告警,这三件事做完,绝大多数"看起来成功实则失败"的任务都会在当天暴露出来,而不是在季度复盘时被业务方发现。
