Agent 可靠性工程实战(七):工具执行加回执,崩溃恢复不再重复副作用
本篇读取上一篇的runs/demo-001/state.json与 085 的steer.json。只有状态为retrying才接受下一次工具建议目标摘要、规范化参数和尝试序号共同生成call_id。本篇新增receipts/call_id.json并把回执摘要追加到events.jsonl作为下一篇故障回放的输入。一、超时以后最难回答的是“到底执行了吗”执行器调用外部工具工具已经写完文件但进程在记录结果前崩溃。恢复后若再次执行可能重复发邮件、重复创建工单或重复扣款若直接跳过又可能遗漏实际上没完成的动作。网络世界不存在靠一次函数返回就解决的“恰好一次”只能用稳定请求键、幂等目标和持久回执逼近可恢复语义。call_id不能随机生成否则恢复时无法认出同一意图。它由契约摘要、工具名、规范化参数和逻辑尝试号的规范 JSON 求摘要。同一尝试的同一动作得到同一 ID参数变化则得到新 ID。回执先写临时文件、同步再原子替换到目标名。from__future__importannotationsimporthashlibimportjsonimportosfrompathlibimportPathfromtypingimportAnydefcanonical(data:Any)-bytes:returnjson.dumps(data,sort_keysTrue,ensure_asciiFalse,separators(,,:)).encode(utf-8)defmake_call_id(contract_sha:str,attempt:int,tool:str,args:dict[str,Any])-str:identity{contract_sha256:contract_sha,attempt:attempt,tool:tool,args:args,}returnhashlib.sha256(canonical(identity)).hexdigest()defwrite_receipt(path:Path,receipt:dict[str,Any])-None:path.parent.mkdir(parentsTrue,exist_okTrue)temporarypath.with_suffix(.tmp)encodedjson.dumps(receipt,ensure_asciiFalse,indent2)withtemporary.open(w,encodingutf-8)ashandle:handle.write(encoded)handle.flush()os.fsync(handle.fileno())os.replace(temporary,path)defmain()-int:rootPath(runs/demo-001)statejson.loads((root/state.json).read_text(encodingutf-8))steerjson.loads((root/steer.json).read_text(encodingutf-8))ifstate[name]!retrying:raiseSystemExit(fstate forbids tools:{state[name]})args{path:workspace/src/price.py,patch_sha256:5c61e2}call_idmake_call_id(steer[contract_sha256],state[attempts]1,apply_patch,args)receipt_pathroot/receipts/f{call_id}.jsonifnotreceipt_path.exists():write_receipt(receipt_path,{call_id:call_id,status:committed,tool:apply_patch,args:args})print(fcall_id{call_id[:12]}receipt{receipt_path.name}reusedTrue)return0if__name____main__:raiseSystemExit(main())运行输出call_id54703f6f4ab1 receipt54703f6f4ab1a892.json reusedTrue二、回执存在不代表结果可信回执至少包含请求身份、状态、输入摘要、输出摘要和工具版本。只写successtrue无法证明修改了哪个文件。执行本地补丁后应重新读取目标文件并保存摘要调用远端 API 时把call_id作为对方支持的幂等键并记录对方返回的资源 ID。恶意或损坏回执也会导致错误跳过所以读取时重新计算身份并检查契约摘要、工具和参数是否一致。回执目录不对 Agent 开放写权限。记忆点是幂等键识别意图结果摘要识别事实。两者缺一恢复只能猜测。importjsonfrompathlibimportPathdefverify_receipt(path:Path,expected_id:str)-dict:receiptjson.loads(path.read_text(encodingutf-8))ifreceipt.get(call_id)!expected_id:raiseValueError(receipt identity mismatch)ifreceipt.get(status)notin{committed,failed}:raiseValueError(receipt has unfinished status)returnreceipt rootPath(runs/demo-001)pathssorted((root/receipts).glob(*.json))receiptverify_receipt(paths[-1],paths[-1].stem)print(freceipt_okTrue status{receipt[status]}tool{receipt[tool]})运行输出receipt_okTrue statuscommitted toolapply_patch三、两阶段状态缩小不确定窗口对不可查询的外部副作用可先写prepared回执再执行工具成功后更新为committed。崩溃若留下prepared恢复不能盲目重试应查询目标系统或交给人工确认。对支持幂等键的服务重复提交同一call_id通常能安全获得原结果。本地文件替换相对容易补丁写到临时工作树检查通过后用原子重命名提交。多文件变更无法靠多个os.replace形成整体事务可以借助 Git 工作树或内容寻址目录把一个提交哈希当原子版本。四、参数规范化的坑{limit: 10}与{limit: 10.0}在业务上可能相同路径大小写在不同文件系统上语义不同。身份生成前必须由工具自己的 Schema 负责类型转换、默认值补齐和路径规范化通用序列化器不能猜业务等价性。工具版本也要进入身份实现语义变更后即使参数相同也不应复用旧回执。验收时在“工具完成后、写回执前”和“写临时回执后、原子替换前”注入崩溃。恢复结果应分别进入查询/人工确认和完成写入路径不能产生第二份工件。本篇回执集合将作为下一篇replay.py的输入与事件账本一起在无模型、无真实副作用的环境中重演决策。回执还应区分可重试失败与永久失败。网络超时只说明结果未知参数校验失败则说明请求未执行把两者都写成failed会诱导错误重试。推荐使用prepared、committed、rejected、unknown四态并让每个工具适配器声明查询未知结果的方法。没有查询能力的外部动作默认需要人工确认。清理回执也不能按“超过七天就删”简单处理。只要事件账本仍引用某个call_id对应回执就是可重放证据应按整个任务的保留策略一起归档或销毁。摘要可以长期保留但包含远端资源标识和参数的正文可能受数据合同限制。参考来源AWSMaking retries safe with idempotent APIsPython 文档os.replace 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《Agent可靠性工程实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。