Pydantic AI v2.36 持久执行不是 exactly-once:幂等与恢复门禁 核心判断Pydantic AIv2.36.0把第三方持久执行后端提升为公开契约但“能恢复”不等于“副作用只发生一次”。只要外部动作发生在检查点提交之前Worker 崩溃或网络重试就可能再次执行你必须为工具和 Capability 设计幂等键、去重记录或补偿动作。与此同时clai --mcp-config读取的是能够启动本地进程并展开环境变量的受信配置配置文件本身应按可执行供应链审计。这篇文章面向维护 Agent Runtime、后台任务和 MCP 工具链的工程师。目标不是把一个版本号翻译成“升级即可”而是给出一条可以分阶段上线、每阶段都有证据、出问题能退回的迁移路径。文中版本和 API 语义来自 Pydantic AI 官方 Release 与文档本文没有在本机安装、运行或迁移真实持久任务示例输出会明确标注为预期结果不把接口形状写成生产实测。1.v2.36.0新增的不是一个“自动恢复开关”官方 Release 将第三方持久执行引擎的适配点公开为DurableOperationBackend。后端可以接管模型调用、工具发现与执行、事件处理、消息压缩以及 Capability Operation。官方文档同时给出 Restate、AWS Lambda、Absurd 等外部适配方向但“官方列出适配方向”不代表你的网络、身份、重试和数据保留已经通过验证。Capability 可以通过durable_operation(name...)声明持久操作并且 Capability 需要稳定的id。这两个字段一旦进入持久化快照就不再只是代码里的装饰信息改名可能导致旧执行无法恢复或无法命中原结果改变id可能让恢复器把同一操作当成新能力参数和结果无法序列化时检查点即使写入成功也不能可靠重放版本升级后改变返回结构旧快照可能在恢复阶段才暴露错误。因此迁移门禁至少要同时冻结操作名、Capability ID、参数 Schema、结果 Schema 和序列化格式。依赖安装成功只能证明包管理器完成了解析不能证明旧任务能恢复。2. 先把一次 Agent 操作拆成四个状态不要直接把“调用工具”包装成一个巨大函数。持久执行需要区分四个状态否则你无法判断重试到底重复了哪一段状态发生的事情可以安全重试吗需要留下的证据planned生成操作身份、参数摘要和幂等键可以前提是键稳定operation ID、Capability ID、参数哈希startedWorker 取得租约并开始调用只能按租约规则attempt、worker、deadlineside_effected外部系统已经产生副作用不能盲目重试provider request ID、去重结果checkpointed结果和下一步状态成功提交可按快照恢复snapshot version、序列化版本最危险的窗口是side_effected到checkpointed之间。比如支付、发送邮件、创建 GitHub Issue 或写入业务数据库已经成功但 Worker 在提交快照前断电。恢复器只看见“没有完成的操作”再次调用就会产生第二次副作用。后端是否提供 at-most-once 语义必须由你核验默认不能把持久执行宣传成 exactly-once。3. 幂等键必须进入业务接口而不是藏在重试器里下面的 Python 代码表达一个与 Web 框架无关的接口形状。它没有调用 Pydantic AI也没有连接真实支付或邮件服务store与provider是需要由你的系统实现和测试的边界fromdataclassesimportdataclassfromhashlibimportsha256fromtypingimportProtocolclassIdempotencyStore(Protocol):defclaim(self,key:str)-str:...deffinish(self,key:str,result:str)-None:...defread(self,key:str)-str|None:...classProvider(Protocol):defcharge(self,*,amount:int,request_id:str)-str:...dataclass(frozenTrue)classChargeInput:operation_id:strtenant_id:strinvoice_id:stramount:intdefstable_key(data:ChargeInput)-str:raw|.join([data.tenant_id,data.invoice_id,data.operation_id,str(data.amount),])returnsha256(raw.encode(utf-8)).hexdigest()defcharge_once(data:ChargeInput,store:IdempotencyStore,provider:Provider)-str:keystable_key(data)previousstore.read(key)ifpreviousisnotNone:returnprevious claim_statestore.claim(key)ifclaim_statecompleted:cachedstore.read(key)ifcachedisNone:raiseRuntimeError(idempotency_record_incomplete)returncachedifclaim_state!claimed:raiseRuntimeError(idempotency_claim_conflict)# Provider 也必须收到同一个 request_id只在本地去重是不够的。resultprovider.charge(amountdata.amount,request_idkey)store.finish(key,result)returnresult这里有三个容易被忽略的细节。第一键要绑定租户、业务对象、持久操作身份和关键参数不能只用随机 UUID随机 UUID 会让一次恢复看起来像新请求。第二Provider 侧如果支持请求级幂等键也要传同一个值本地数据库去重成功后网络超时仍可能让 Provider 已经扣款。第三claim、finish和read需要明确并发语义不能用一个普通缓存get/set代替原子占用。3.1 参数变更如何处理参数 Schema 变化时不要复用旧键。可以把 Schema 版本纳入键的组成并在恢复器中保留兼容解析operation_id op_20260830_01 capability_id billing.charge schema_version 2 idempotency_key sha256(tenant | invoice | operation | amount | schema_version)如果金额单位从“分”改成“元”即使字段名没有变业务含义也变了。恢复旧快照时应先识别schema_version1再显式迁移到内部模型无法证明等价时宁可进入人工补偿队列也不要自动重放。4. 恢复测试要故意打断“副作用—检查点”窗口只测试“任务成功完成后可以读取结果”没有意义真正需要的是故障注入。建议为一个 Capability 准备两个版本并模拟以下序列创建 Capability billing.charge版本 v1 → 生成 operation_id 与幂等键 → Provider 返回 request_idK副作用已完成 → 在 checkpoint 写入前强制 Worker 崩溃 → 恢复同一个 operation_id → 断言 Provider 收到同一个 K第二次调用被去重 → 提交 checkpoint读取结果与第一次一致预期的拒绝样本应该长这样这是测试设计不是本机运行输出scenariocross_operation_replay operation_idop_20260830_01 provider_calls1 decisiondeduplicated scenariorenamed_operation old_namebilling.charge new_namebilling.charge_v2 decisionblocked reasonoperation_identity_changed你还需要覆盖以下失败参数哈希变化、结果无法反序列化、租约过期后两个 Worker 同时恢复、取消信号到达但 Provider 已经提交、后端不可用、事件重复投递以及观测上下文丢失。每个失败都应产生结构化事件而不是只在日志里打印一行异常。5. 四阶段迁移顺序先稳定身份再切默认后端阶段一冻结快照与版本锁定 Pydantic AIv2.36.0、后端适配器版本和序列化库版本。为每类操作保存一份脱敏快照至少包含operation_id、Capability ID、操作名、Schema 版本、参数摘要、结果摘要和当前 checkpoint。不要把 API Key、文件内容或完整用户输入放进快照。过关证据是同一份快照在开发、预发布和生产恢复器中得到一致的身份解析结果。若依赖升级让旧快照只能在新解释器中打开先停止切换默认后端。阶段二隔离 DurableOperationBackend业务层只依赖内部DurableRunner接口不直接引用 Restate、Lambda 或其他后端的重试 API。适配层负责把模型、工具和事件调用映射到后端传播 operation ID、租约、deadline 和取消信号在提交检查点前记录副作用状态将后端错误映射成可观测的retryable、blocked或compensate。不要因为某个后端示例使用了“自动重试”就把所有异常都标记为可重试。网络超时、鉴权失败、参数校验失败和 Provider 已执行后的未知状态处理策略完全不同。阶段三运行身份与幂等回归先不切换线上默认后端在影子环境回放脱敏快照。至少验证改名被阻断、旧快照仍能解析、重复事件不增加副作用、部分副作用进入补偿、租约冲突不会双写、取消能传递到工具、观测字段在恢复后仍然相同。阶段四按租户或请求切换稳定入口和旧适配器并行一段时间。通过租户或请求级开关切换保留快速回滚到旧适配器的路径但不要回滚已经写入的幂等记录和授权记录。回滚只改变“谁负责调度”不能让同一个业务键重新变成新键。6.clai --mcp-configJSON 文件也可能是命令执行入口Pydantic AI CLI 文档中的clai --mcp-config可以读取包含mcpServers的配置并在流式输出中展示工具调用状态。配置能够指定本地可执行命令也能从完整进程环境展开${VAR}。因此能够写入配置的人实际获得了启动进程和读取环境变量的能力。把配置当普通数据上传给 Agent 是一个危险的默认值。以下配置片段的风险不在 JSON 语法而在command与env的语义{mcpServers:{repo-tools:{command:node,args:[/opt/approved/repo-mcp.mjs],env:{WORKSPACE:${APP_WORKSPACE}}}}}上线前至少执行以下检查配置文件的所有者、权限和来源可追溯command只能命中固定 allowlist禁止聊天内容直接提供路径args不允许隐式 shell 展开禁止拼接未经校验的用户输入环境变量采用最小集合不能把完整生产环境传给 MCP 子进程配置版本、工具版本、镜像 Digest 和批准人进入审计读取配置前做 Schema 校验未知字段默认拒绝子进程的 stdout、stderr、退出码和超时进入观测但不记录秘密。一个最小的 Python 校验骨架如下frompathlibimportPathimportjson ALLOWED_COMMANDS{node,python}ALLOWED_ENV{WORKSPACE,READ_ONLY}defload_mcp_config(path:str)-dict:pPath(path).resolve()ifp.stat().st_mode0o022:raisePermissionError(mcp_config_writable_by_group_or_other)datajson.loads(p.read_text(encodingutf-8))serversdata.get(mcpServers)ifnotisinstance(servers,dict)ornotservers:raiseValueError(mcp_servers_missing)forname,iteminservers.items():ifset(item)-{command,args,env}:raiseValueError(funknown_field:{name})ifitem.get(command)notinALLOWED_COMMANDS:raisePermissionError(fcommand_not_allowed:{name})ifset(item.get(env,{}))-ALLOWED_ENV:raisePermissionError(fenv_not_allowed:{name})returndata这个示例只做配置门禁不负责启动进程。真正执行时还要固定工作目录、资源限制、网络出口、文件系统只读范围和取消行为。配置文件通过校验也不代表 MCP 工具本身安全工具仍需单独做输入 Schema、权限和数据出站检查。7. 把持久执行与 MCP 放进同一条审计链持久后端和 MCP 配置经常被两个团队分别维护结果是调度层记录了 operation ID工具层却不知道它来自哪个租户、哪个配置版本。建议每次工具调用都带上以下不可变上下文{operation_id:op_20260830_01,capability_id:repo.read,schema_version:3,mcp_config_version:cfg-17,tool_version:repo-mcp2026.08.30,tenant_id:tenant-a,attempt:2,idempotency_key_hash:sha256:...}日志里只保存不可逆摘要和内部引用不保存完整 Token、Authorization Header、文件内容或环境变量值。恢复后attempt可以增加但operation_id、Capability ID 和幂等键不能悄悄变化。出现schema_version不兼容、配置版本不存在或租户不匹配时失败关闭不要“先执行再补审计”。8. 最小回归矩阵成功用例不够拒绝用例才说明边界存在场景预期硬断言稳定后端读取旧快照允许operation ID 与 Schema 版本可解析操作名被改写拒绝或显式迁移不把新名字当成旧身份Capability ID 重复拒绝注册表不出现隐式覆盖参数改变后恢复拒绝参数哈希不匹配时不调用 Provider副作用后 checkpoint 前崩溃去重恢复Provider request ID 只产生一次副作用两个 Worker 同时恢复一个成功另一个得到租约冲突Provider 超时但状态未知进入补偿不自动生成新幂等键取消到达工具边界可观测停止后续不再提交新的副作用结果无法反序列化失败关闭保留原快照和错误版本MCP command 不在 allowlist拒绝子进程启动次数为 0MCP 配置可被组写入拒绝配置加载在执行前失败环境变量超出最小集合拒绝不把完整环境传给子进程其中“Provider 请求次数为 0”是跨租户、Schema 不匹配和 MCP 配置拒绝场景的关键断言。只检查最终返回错误码无法证明副作用没有发生。9. 回滚、补偿与未覆盖边界如果稳定后端在生产出现恢复差异先把默认调度切回旧适配器保留已经写入的快照、幂等记录和审计事件。不要删除“看起来失败”的任务因为它可能已经在外部系统产生副作用。对支付、消息、工单等不可逆动作应该进入人工补偿队列由业务方确认后再关闭。如果发现 MCP 配置来自聊天生成内容立即冻结新任务并轮换配置版本不要通过放宽 allowlist 来“让流程先跑起来”。如果只是某个工具版本缺失可以回滚工具版本但仍需重新跑 Schema、权限、网络和取消测试。本文没有覆盖真实 Restate、AWS Lambda 或 Absurd 部署没有验证你的云 IAM、网络拓扑、跨区域恢复、Provider 账单、模型训练条款或数据保留策略。v2.36.0的公开 API 也不替代具体后端的 SLA 与故障语义。是否能做到 exactly-once需要逐个外部副作用确认工程上更稳妥的默认值是“至少一次调度 幂等或补偿”。结语把“可恢复”改写成一组可证明的门禁升级 Pydantic AI 的持久执行能力第一步不是把beta替换成新导入而是给每个操作建立稳定身份冻结 Schema记录检查点并在副作用前后都保留证据。第二步是把clai --mcp-config当成执行供应链处理来源、命令、参数、环境和工具版本都要经过 allowlist 与审计。第三步才是按租户或请求切换后端并保留只切适配层的回滚开关。上线前可以用下面的清单做最后检查DurableOperationBackend适配层与业务层已隔离operation name、Capability ID、参数 / 结果 Schema 和序列化版本已冻结副作用使用稳定幂等键Provider 端也能去重或有补偿方案恢复测试覆盖“副作用完成但 checkpoint 未提交”的窗口MCP 配置来源、权限、命令 allowlist、最小环境和版本均可回读跨租户、Schema 不匹配、配置拒绝时 Provider / 子进程调用次数为零观测记录 operation ID、attempt、配置版本和拒绝原因但不泄露秘密回滚不会删除幂等记录也不会把已执行的外部动作当成未发生。做到这些持久执行才不是一句“任务会自动继续”的宣传而是一条能够解释身份、状态、副作用和恢复结果的证据链。官方来源Pydantic AI v2.36.0 ReleaseDurable Execution BackendsCustom CapabilitiesPydantic AI CLI本文验证边界官方事实v2.36.0的 DurableOperationBackend、durable_operation、Capability 稳定 ID 与clai --mcp-config配置形状。工程判断幂等键设计、四阶段迁移、MCP allowlist、回归矩阵、审计字段和补偿策略。未验证项未在本机安装或运行 Pydantic AI未连接 Restate / Lambda / Absurd未启动真实 MCP 子进程未执行真实 Provider 副作用或故障注入。