Demo 里的 agent 永远成功;production 里的 agent 大部分时间在处理失败。
Agent 的 happy path 谁都会写:while 循环 + tool call + 模型决定停。但生产环境里,90% 的工程量在 happy path 之外——网络抖、rate limit、tool 超时、模型吐半个 JSON、第 38 步崩了前 37 步白跑、retry 把同一封邮件发了三遍。这些不是 prompt 能解决的,是 harness 的韧性设计。资深分布式工程师对这套东西很熟(retry / 幂等 / saga / 熔断 / checkpoint),但 agent 把它们重新搅在一起:因为多了一个非确定性的决策层,很多传统 retry 直觉会失效——模型自己会"再调一次",模型自己会因为一个错误而放弃。这一期讲四件最容易出线上事故的事:retry 的两层结构、副作用工具的幂等与补偿、长任务的 checkpoint 续跑、超时熔断与部分失败的降级。
agent 里有两种"失败",处理方式完全相反。
tool_result 喂回模型,让它换个方法。绝不能在程序层 retry——同样的参数重试一百次还是错。头号 bug 是把两层搞反:把 429 当语义错误喂回模型(模型不会退避,继续猛打依赖),或把"文件不存在"当传输错误程序重试(死循环)。判别只有一句话:错误是否与输入无关、且只是瞬时的?是→程序退避;否→喂回模型。
import time, random, anthropic
TRANSIENT = (anthropic.RateLimitError, anthropic.APITimeoutError,
anthropic.InternalServerError)
def call_with_backoff(fn, max_retries=5):
for attempt in range(max_retries):
try:
return fn()
except TRANSIENT as e: # 传输层:确定性瞬时故障 → 程序退避
if attempt == max_retries - 1: raise
# full jitter:随机化整个退避窗口,避免 thundering herd
time.sleep(random.uniform(0, min(60, 2 ** attempt)))
# 注意:语义错误(tool 报错)不在这里 catch——
# 它们走 tool_result 喂回模型,由模型决定下一步
retry 和断点续跑有个隐藏前提:被重试的操作可以安全地执行多次。读操作天然幂等(读两遍文件没事),但写操作不是:create_order / send_email / charge_card 重试一次 = 客户被扣两次款。agent 比传统系统更危险,因为它的"重试"有两个来源:程序层 backoff,和模型层——模型看到 tool_result 含糊(超时,不知道成没成功)时,会"保险起见再调一次"。这恰恰是最危险的时刻,因为首次可能已经成功了。
两个工程手段:
# 1) 幂等键:让重试安全。key 由 task 派生,而非随机
def book_flight(args):
key = f"book:{args['trip_id']}:{args['flight_no']}" # 确定性 key
return api.post("/bookings", json=args,
headers={"Idempotency-Key": key}) # 服务端去重
# 2) 补偿:每个副作用 step 登记一个 undo,失败时逆序回滚
saga = []
try:
r1 = book_flight(...); saga.append(lambda: cancel_flight(r1.id))
r2 = charge_card(...); saga.append(lambda: refund(r2.id))
r3 = book_hotel(...) # ← 这一步失败
except Exception:
for undo in reversed(saga): # 逆序补偿:先退款,再退票
undo()
raise
tool_result 是 network timeout(真不知道服务端执行了没)时,模型经常会再调一次——而幂等键是唯一可靠防线。不能靠 prompt 叮嘱模型"不要重复",那是把安全性建在非确定性的决策层上。
一个跑 40 步、半小时的 agent,在第 38 步进程崩了(OOM、部署重启、抢占式机器被回收)。如果 state 只活在内存的 messages 数组里,前 37 步全部白跑——而那 37 步的 token 和副作用已经花掉了。生产 agent 必须把 state 持久化到进程外:每完成一步就 checkpoint 一次,崩溃后从最后一个 checkpoint 恢复。这就是 durable execution 的核心。两种落地:
thread_id 恢复,"loads the last snapshot and resumes exactly where it left off"。关键是 checkpoint 的粒度:太细(每个 token)开销大,太粗(只在结束)等于没有。实践上以"完成一次模型调用或一个有副作用的 step"为单位。且 checkpoint 必须配合幂等(§2)——恢复时最后那个 step 可能只执行了一半。
import json, os
def agent(task, ckpt_path):
if os.path.exists(ckpt_path): # 续跑:从 checkpoint 恢复
s = json.load(open(ckpt_path)); msgs, step = s["msgs"], s["step"]
else:
msgs, step = [{"role":"user","content":task}], 0
while step < MAX_STEPS:
r = client.messages.create(model=MODEL, messages=msgs,
tools=SCHEMAS, max_tokens=4096)
msgs.append({"role":"assistant","content":r.content})
if r.stop_reason == "end_turn": break
results = run_tools(r.content) # 执行工具(须幂等!)
msgs.append({"role":"user","content":results}); step += 1
json.dump({"msgs":msgs,"step":step}, open(ckpt_path,"w")) # 每步落盘
三个相关机制:
closed → open → half-open。class CircuitBreaker:
def __init__(self, fail_max=5, reset_after=30):
self.fail_max, self.reset_after = fail_max, reset_after
self.fails, self.opened_at = 0, None
def call(self, fn):
if self.opened_at: # OPEN
if time.time() - self.opened_at < self.reset_after:
raise CircuitOpen("依赖熔断中,快速失败")
self.opened_at = None # → HALF-OPEN:放一个试探
try:
out = fn(); self.fails = 0 # 成功 → CLOSED
return out
except Exception:
self.fails += 1
if self.fails >= self.fail_max:
self.opened_at = time.time() # → OPEN:跳闸
raise
拿你手上任何一个 100 行的 demo agent,按这张 checklist 逐项加固——每一项都对应上面一个失败模式:
messages.create 和网络型 tool 套 call_with_backoff(full jitter);语义错误不包进去,原样喂回模型。Idempotency-Key;不能改服务端的,就自己存一张"已执行 key"表去重。{msgs, step} 每轮落盘(或 LangGraph checkpointer);启动时先尝试恢复。验证:跑到一半 kill -9,重启应从断点继续而非从头。CircuitBreaker。gather 改成"收集成功 + 标注失败",让上层决定继续还是停。做完你会发现:代码从 100 行涨到 300+ 行,多出来的 200 行没有一行是"功能",全是韧性——这正是 demo agent 和 production agent 的真实差距。
charge:{user_id}:{order_id}:{amount} 这种由确定字段拼出的 key,无论模型怎么措辞、重试几次都一样。如果操作本身缺乏天然唯一标识(如"发一条鼓励消息"),就在 agent 进入该 step 时由 harness 生成一个 key 并写进 checkpoint——续跑时复用 checkpoint 里的 key。本质:幂等键的稳定性来自 harness 层的确定性派生,而不是模型输出。