上一篇,我们跑通了最短的一条路:程序把 messages 发给模型,再从响应里取回一条文本。
只要网络正常、API Key 有效、请求格式正确,这十几行代码就能工作。但真实系统不会永远一切正常。销售助手刚开始接待客户,模型调用就可能抛出 SDK 异常:有时是密钥错了,有时是网络没通,有时则是程序自己发出了不合法的请求。
异常是程序报告"执行没有按预期完成"的方式。如果这三种异常在业务代码眼里都只是"调用模型失败",程序就无法决定下一步该做什么:重试、停止、提醒运维,还是回头修改请求?
所以这一篇先不增加任何 Agent 能力。我们只做一件更基础的事:亲手制造三种失败,观察它们真实的样子,再让业务代码看到一组稳定、可采取行动的错误类型。
文章对应代码仓库 agent-from-zero 的 v0.0.2-error-handling。你可以切换到这个 tag,对照 src/agent/llm.py 和 tests/test_llm.py 运行。
为什么第二篇就先谈 Error
很多教程会先不断增加能力,等功能看起来完整以后,才补错误处理、延迟和成本记录。对 Agent 来说,这个顺序风险很大,因为模型不是进程里的一个普通函数:每次调用都要经过网络,依赖外部模型服务,还会受到凭证、请求格式、服务负载和限额等因素影响。
这里的 Latency(延迟),指从程序发出请求到拿到结果所经历的时间。错误决定"一次调用有没有完成",延迟决定"用户要等多久",成本决定"这种调用能否持续运行"。它们不是附加在 Agent 外面的监控数据,而是程序决定重试、停止、降级或继续执行时必须参考的运行信号。
更重要的是,后面的 Agent Loop 可能为一个用户问题连续调用模型和多个工具。一次调用失败,整条任务链可能中断;每一步多等一秒,总等待时间就会累加;每多调用一次模型,费用也会增加。趁系统还只有一条最短调用路径,先建立错误、延迟和成本边界,后面增加工具与循环时,我们才能看清复杂度究竟从哪里产生。
上一版代码有什么问题
上一篇的 chat() 直接调用 SDK:
response = get_client().chat.completions.create(...)只要这一行失败,openai SDK 的原始异常就会一路穿过 chat(),直接落到未来的 Agent Loop、接口层或命令行里。这叫异常向上传播:当前函数没有处理它,于是交给调用当前函数的上一层处理。
最省事的处理方式似乎是:
try:
return chat("A100 还有多少库存?")
except Exception:
return "模型暂时不可用"它确实不会再让程序崩溃,但也把最有价值的信息一起抹掉了。
API Key 写错,重复调用一百次仍然会失败;偶发超时,多试一次却可能恢复;模型名不存在,则需要修正请求。三种问题需要三种动作,不能被同一句"暂时不可用"吞掉。
我们真正需要的不是一个更大的 try/except,而是一张能指导后续行为的 failure map。
不猜,先把失败真实地触发出来
这一步没有先写异常映射。我们先对真实的 DeepSeek API 做三个有意失败的实验,记录 SDK 实际抛出的类型和信息。
这种顺序很重要。异常分类如果只凭经验设计,很容易把"想象中的错误形状"写进程序;真正接入服务后,才发现状态码、继承关系或 SDK 封装方式并不是原先以为的样子。这里的状态码是服务器随响应返回的数字,用来概括请求结果,例如 400 表示请求有问题,401 表示身份认证失败。
实验一:使用错误的 API Key
把客户端换成一个明确无效的密钥:
client = OpenAI(
api_key="sk-invalid-wrong-key-00000000000000",
base_url="https://api.deepseek.com",
)
client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "hi"}],
)真实结果是:
TYPE: openai.AuthenticationError
MESSAGE: Error code: 401 - {
'error': {
'message': 'Authentication Fails, Your api key: ****0000 is invalid',
'type': 'authentication_error',
'code': 'invalid_request_error'
}
}AuthenticationError 表示认证失败,也就是服务无法确认当前调用者拥有有效凭证。401 是对应的 HTTP 状态码。只要凭证不变,原样重试不会突然成功;正确动作是停止请求并修复 API Key。
实验二:让网络不可达,再制造一次超时
网络失败至少有两种常见外观:根本连不上,以及连接过程超过时间限制。我们分别测了一次。
把 base_url 指向不存在的域名,模拟 DNS 解析失败。DNS 负责把 api.deepseek.com 这样的域名转换成网络地址;如果解析失败,程序连服务器在哪里都找不到。SDK 抛出:
TYPE: openai.APIConnectionError
MESSAGE: Connection error.仍然访问真实 DeepSeek 地址,但把 timeout 压到 0.01 秒。timeout 是程序愿意等待请求完成的最长时间,超过限制就主动结束等待。SDK 抛出:
TYPE: openai.APITimeoutError
MESSAGE: Request timed out.表面上,这是两个异常类型。但检查 Python 类的继承关系后会看到:
>>> openai.APITimeoutError.__mro__
(
APITimeoutError,
APIConnectionError,
APIError,
OpenAIError,
Exception,
BaseException,
object,
)子类会被针对父类的 except 捕获,而 APITimeoutError 正是 APIConnectionError 的子类。因此:
except APIConnectionError:
...已经可以同时接住"连不上"和"超时"。
这不是为了少写一行代码而研究语法细节。站在当前业务层看,两者表达的是同一件事:这次调用没有打通,但请求和凭证未必有问题。 它们以后都可能进入重试、退避或降级流程。退避是指连续重试时逐渐延长等待时间;降级则是在主路径不可用时,改用能力较弱但仍可工作的备用方案。
实验三:发送一个不合法的请求
为了稳定、便宜地触发请求错误,我们把模型名换成一个不存在的值:
client.chat.completions.create(
model="deepseek-chat-nonexistent-model",
messages=[{"role": "user", "content": "hi"}],
)真实结果是:
TYPE: openai.BadRequestError
MESSAGE: Error code: 400 - {
'error': {
'message': '...you passed deepseek-chat-nonexistent-model.',
'type': 'invalid_request_error',
'code': 'invalid_request_error'
}
}BadRequestError 表示服务收到了请求,但认为请求内容不合法;400 是对应的 HTTP 状态码。模型名、参数或输入内容不变,重新发送相同请求通常不会带来不同结果。程序应当修改请求,而不是盲目重试。
原计划还考虑用"超长输入"制造 BadRequestError,但这会生成大量无意义内容,还会受到不同模型上下文窗口大小的影响。上下文窗口是一次请求能够容纳的最大输入和输出范围。不存在的模型名更快、更便宜,也能稳定触发同一类问题。
从异常名称转向处理动作
三个实验结束后,分类已经自然出现了:
| 真实原因 | SDK 异常 | 应用层分类 | 当前应该做什么 |
|---|---|---|---|
| API Key 错误 | AuthenticationError | AuthenticationFailure | 停止并修复凭证 |
| 连接失败或超时 | APIConnectionError / APITimeoutError | NetworkFailure | 允许进入重试或降级 |
| 请求内容不合法 | BadRequestError | BadRequestFailure | 停止并修正请求 |
这张表不是完整的错误清单。权限不足、请求过于频繁、服务端故障等情况还没有进入应用自己的分类;遇到它们时,当前代码仍会暴露 SDK 原始异常。本篇只处理真实触发并立即需要的三类失败。
还要区分两层"重试":当前 commit 没有实现自己的业务重试策略,但 openai SDK 会默认对部分连接错误、超时、限流和服务端错误进行少量自动重试。限流是指请求频率超过服务允许的上限。因此,这里保存的是业务上是否值得再次尝试的判断;后面还要明确控制最多尝试几次、等待多久,以及什么情况下停止,不能假设底层每次只发送一个网络请求。
把 SDK 异常翻译成自己的语言
现在可以写最小修复了。先定义一组属于应用程序自己的异常。这里的应用层指我们能够控制、并负责业务决策的代码;与之相对,SDK 是外部依赖。
class ChatError(Exception):
"""所有已分类模型调用失败的公共基类。"""
class AuthenticationFailure(ChatError):
"""凭证无效,重试没有意义。"""
class NetworkFailure(ChatError):
"""连接失败或超时,这次调用没有打通。"""
class BadRequestFailure(ChatError):
"""请求不合法,需要修改请求本身。"""再在 chat() 与 SDK 之间增加一层映射:
def chat(
user_message: str,
system: str = DEFAULT_SYSTEM,
temperature: float = 0.0,
) -> str:
try:
response = get_client().chat.completions.create(
model=DEFAULT_MODEL,
temperature=temperature,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_message},
],
)
except AuthenticationError as error:
raise AuthenticationFailure(str(error)) from error
except APIConnectionError as error:
raise NetworkFailure(str(error)) from error
except BadRequestError as error:
raise BadRequestFailure(str(error)) from error
return response.choices[0].message.content这里有两个值得注意的细节。
第一,三个类型都继承 ChatError。它是公共基类,也就是三个具体错误共同的上层类型。简单调用方可以只捕获 ChatError,需要精细恢复策略的调用方则可以捕获某个子类。
第二,重新抛出异常时使用了:
raise NetworkFailure(str(error)) from errorfrom error 保留了原始异常链。业务层得到稳定分类,调试时仍能沿 traceback(异常发生后记录下来的调用路径)找回 SDK 的原始错误,两边的信息都没有丢。
为什么不直接让 Agent Loop 认识 SDK 异常
让调用方直接写 except openai.AuthenticationError 当然也能工作。问题在于,它会让 SDK 的异常体系扩散到每一个上层模块。
今天底层是 DeepSeek 的 OpenAI 兼容接口;以后如果更换模型服务商(Provider)或 SDK,Agent Loop、重试模块、HTTP 接口和测试代码都要跟着认识一套新异常。
现在增加的这层翻译,把变化限制在 llm.py:
Agent / Retry / API
↓
应用自己的 ChatError
↓
Provider SDK 的原始异常这就是一条错误边界:上层只看对业务有意义的错误类型,底层负责吸收不同服务商和 SDK 的实现差异。
一条错误信息,不等于一份权威事实
制造坏请求时还发生了一个插曲:错误消息列出了一些"支持的模型名",却没有列出项目一直在使用的 deepseek-chat。
如果只看字面,很容易立刻认为 deepseek-chat 已经不可用。但单独发起一次正常请求后,它仍然可以返回结果。因此,错误消息可以提供诊断线索,却不一定是完整、权威的模型清单。凡是会影响代码决策的信息,都应该再用一个更小的实验验证,而不是只按报错文案的字面意思行动。
另一个意外:宽松版本约束也会带来变化
项目对 openai 的依赖约束是:
openai>=1.50.0这里的依赖约束规定项目允许安装哪些版本。>=1.50.0 的意思是"至少为 1.50.0",没有限制最高版本;当前实际安装的是 3.13.0。测试在构造 SDK 异常时发现,这一版本底层使用的是 httpx2,而不是原先预期的 httpx。它们都是负责发送 HTTP 网络请求的底层客户端库。
这次变化没有影响 chat() 的运行,只影响了测试如何构造真实异常对象。但它提醒我们:>= 只规定最低版本,并不会让环境长期保持原样。依赖升级也是系统变化的一部分,不能只验证自己写的代码。
这个发现与错误分类不是同一个问题,所以本篇只记录现象,不顺手扩展成依赖锁定方案。什么时候固定版本、怎样升级,以及怎样通过重新运行既有测试确认旧功能没有被破坏,应该单独处理。
用真实观察指导稳定测试
重新运行测试:
pytest -v结果是:
tests/test_llm.py::test_chat_returns_the_model_text PASSED
tests/test_llm.py::test_chat_sends_system_and_user_messages PASSED
tests/test_llm.py::test_chat_accepts_custom_system_and_temperature PASSED
tests/test_llm.py::test_get_client_reads_api_key_from_env PASSED
tests/test_llm.py::TestFailureClassification::test_authentication_error_is_reclassified PASSED
tests/test_llm.py::TestFailureClassification::test_connection_error_is_reclassified_as_network_failure PASSED
tests/test_llm.py::TestFailureClassification::test_timeout_error_is_also_reclassified_as_network_failure PASSED
tests/test_llm.py::TestFailureClassification::test_bad_request_error_is_reclassified PASSED
tests/test_llm.py::TestFailureClassification::test_all_three_failures_share_a_common_base_class PASSED
9 passed in 0.47s新增测试没有每次都真的断网、超时或调用错误凭证。那样既慢,又依赖外部服务状态。
我们的做法是:
- 1.先在真实环境中分别触发三类失败,确认实际异常类型和继承关系;
- 2.再使用 SDK 的真实异常构造函数,在测试里稳定重现这些错误;
- 3.最后用断言——测试中"这个条件必须成立"的检查——确认
chat()是否转换成正确的应用层类型,并保留错误信息与公共基类。
真实调用负责确认外部服务实际上抛出什么;测试替身负责快速、稳定地验证我们自己的转换逻辑。两者职责不同。
留给你的三个实验
- 1.尝试用超长输入触发一次
BadRequestError,比较它与"模型名不存在"的错误消息,但注意控制输入成本。 - 2.当前把 DNS 失败和超时都归为
NetworkFailure。如果后续发现 DNS 配置错误不值得重试,你会在哪一层把它进一步拆开? - 3.分别测试"完全没有设置
DEEPSEEK_API_KEY"和"设置为空字符串"。观察它们是否都会进入AuthenticationFailure,以及当前边界还有什么遗漏。
回到问题:错误类型其实是在决定下一步
这一篇最重要的收获不是记住 AuthenticationError、APIConnectionError 和 BadRequestError 三个名字,而是建立下面这条判断:
失败分类不是为了让报错更整齐,而是为了让程序知道接下来能做什么。
现在,模型调用仍然可能失败,但三种基础失败已经能指导下一步动作:凭证问题需要修复,网络问题可能恢复,坏请求必须修改。未来的 Agent Loop 和重试逻辑只需要理解 ChatError,不需要知道底层服务商如何命名异常。
下一篇会继续沿着同一条最短路径,开始记录每一次模型调用消耗的 token、时间和估算成本。程序不仅要知道"成功还是失败",还要逐渐知道"这次调用付出了什么"。