为什么LLM代理会静默失败以及如何进行调试 - DEV社区

Dev.to AI 2026-06-27T21:20:35.214490

为什么LLM代理会静默失败以及如何进行调试 - DEV社区

你的代理返回了一个空结果。没有异常。没有错误日志。没有任何有用的状态码。什么都没有。

你翻遍日志。LLM调用成功了。工具被调用了。响应返回了。一切看起来正常,但任务却是不完整的、错误的或完全缺失的。

这就是静默失败。它也是AI工程中最棘手的bug之一。


什么是LLM代理中的静默失败?

静默失败是指你的代理在没有抛出异常的情况下完成了执行,但产生了错误或不完整的结果。嘈杂的失败(例如Python回溯、API返回5xx)与静默失败的区别在于,嘈杂的失败是可调试的。而静默失败则需要你为整个代理循环添加检测手段,才能发现出了问题。

这种现象很常见,因为LLM的设计使它们总是返回某些内容。当模型上下文耗尽或者底层工具模式发生变化时,它不会抛出ValueError。它会返回一个空数组、一段截断的JSON、或者自信地说“任务已完成”,但实际上什么成果都没有。

结果是,你的代理看起来正常工作,直到你仔细检查输出才会发现问题。


三个根本原因:令牌预算、工具模式漂移和未处理的异常

大多数静默失败都可以追溯到以下三个原因之一。

令牌预算耗尽。 当在工具调用过程中达到max_tokens限制时,OpenAI的函数调用API会返回一个空的choices数组。不会抛出异常。调用返回200状态码。你的代码检查response.choices[0]时会因IndexError而崩溃,更糟糕的是,你的代码可能会优雅地处理空数组然后继续执行。代理会像工具已经运行过一样继续下去,但没有任何输出。

response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools,
    max_tokens=512  # 对于复杂的工具调用来说太小了
)

# 这会在运行时崩溃——或者如果你做了防御性处理,则会静默跳过
if response.choices:
    tool_call = response.choices[0].message.tool_calls[0]

修复方案:始终记录response.choicesfinish_reasonusage.completion_tokens。如果finish_reason == "length",则将其视为硬性失败,而不是优雅的无效操作。

工具模式漂移。 你的工具模式发生了变化。某个字段被重命名、某个必需参数被移除、或者添加了新的枚举值。LLM是根据旧模式调优的。现在它会生成验证器无法通过的参数,而你的框架会静默丢弃工具输出并继续执行。LangGraph的StateGraph在工具在中断(interrupt)内部引发未处理异常时,正好会执行此操作:输出被丢弃,下一个节点接收到None

# 工具引发异常,StateGraph吞噬异常
@tool
def fetch_user_data(user_id: str) -> dict:
    # 这里的KeyError会被中断处理器吞噬
    return db.fetch(user_id)["profile"]["details"]

修复方案:始终从工具处理器中重新抛出异常,或者将其包装在显式的try/except中,返回结构化的错误负载,而不是向下游传播None

代理循环内部的未处理异常。 大多数代理框架会在编排器级别捕获异常,以保持循环存活。这对可靠性有好处,但这也意味着你的每个步骤错误会被吞噬到一个捕获所有异常的处理程序中,该处理程序不会记录任何有用信息,并允许下一轮继续执行。一个10步链条中的一个糟糕的工具调用会静默地毒化后续的每一步。


添加逐步骤追踪以尽早捕获故障

揭示静默失败最可靠的方法是分布式追踪。每个代理步骤使用OpenTelemetry跨度(span)可以为你提供每个工具调用、其输入、输出以及失败位置的查询记录。

from opentelemetry import trace

tracer = trace.get_tracer("agent.loop")

def run_agent_step(step_name: str, messages: list, tools: list):
    with tracer.start_as_current_span(step_name) as span:
        span.set_attribute("step.input_message_count", len(messages))
        span.set_attribute("step.tool_count", len(tools))

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools,
        )

        finish_reason = response.choices[0].finish_reason if response.choices else "empty"
        span.set_attribute("step.finish_reason", finish_reason)
        span.set_attribute("step.completion_tokens", response.usage.completion_tokens)

        if finish_reason == "length" or not response.choices:
            span.set_status(trace.StatusCode.ERROR, "token budget hit or empty response")
            raise RuntimeError(f"Step {step_name} hit token budget before completing")

        return response

现在,当出现问题时,你的追踪显示具体是哪个步骤失败以及原因。你不需要从分散的日志行中重建故障。你拥有完整的跨度树。

标题:为什么 LLM 代理会无声失败以及如何调试它们 - DEV Community

原文:
将此插入任何 OpenTelemetry 兼容的后端(Honeycomb、Jaeger、OTel Collector),即可免费实时了解代理循环的运行情况。


结构化输出验证作为无声失败的防火墙

如果追踪能告诉你问题出在哪里,那么 Pydantic 能告诉你模型产出的内容如何打破了你的假设。

每次工具调用后添加一个 Pydantic 验证步骤。模型输出的模式在触及下游任何内容之前先进行验证。如果验证失败,你会捕获一个带有明确消息的 ValidationError,而不是一个无声的 None 在后续 5 个步骤中传播。

from pydantic import BaseModel, ValidationError

class UserProfile(BaseModel):
    user_id: str
    email: str
    role: str  # "admin" | "viewer" | "editor"

def validate_tool_output(raw: dict) -> UserProfile:
    try:
        return UserProfile(**raw)
    except ValidationError as e:
        # 此处故意制造一个响亮的失败——比之后无声的失败要好
        raise RuntimeError(f"工具输出未通过模式验证: {e}") from e

这对于调用外部 API 的工具尤其强大。外部模式独立于代理的预期发生变化。Pydantic 在边界处捕获这种不匹配,防止过时数据流入 LLM 的后续提示并污染后续运行。


在长时间运行的代理循环中构建死亡开关

长时间运行的代理(跨多个工具调用运行数分钟或数小时的代理)需要一个存活检查,当循环静默时间过长时触发。如果代理在 N 秒内没有签到,那么就意味着在某个环节它被认为还活着但实际上已经失效。

import threading
import time

class AgentWatchdog:
    def __init__(self, timeout_seconds: int = 60):
        self.timeout = timeout_seconds
        self.last_heartbeat = time.time()
        self._stop = threading.Event()

    def heartbeat(self):
        """每次成功执行代理步骤后调用此方法。"""
        self.last_heartbeat = time.time()

    def start(self):
        def _watch():
            while not self._stop.is_set():

查看原文