☰
Pydantic AI v2.36 持久执行不是 exactly-once:幂等与恢复门禁
2026/10/13 10:34:59 网站建设 项目流程

核心判断: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、deadline
side_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(frozen=True)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:key=stable_key(data)previous=store.read(key)ifpreviousisnotNone:returnprevious claim_state=store.claim(key)ifclaim_state=="completed":cached=store.read(key)ifcachedisNone:raiseRuntimeError("idempotency_record_incomplete")returncachedifclaim_state!="claimed":raiseRuntimeError("idempotency_claim_conflict")# Provider 也必须收到同一个 request_id;只在本地去重是不够的。result=provider.charge(amount=data.amount,request_id=key)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_version=1,再显式迁移到内部模型;无法证明等价时,宁可进入人工补偿队列,也不要自动重放。

4. 恢复测试要故意打断“副作用—检查点”窗口

只测试“任务成功完成后可以读取结果”没有意义,真正需要的是故障注入。建议为一个 Capability 准备两个版本,并模拟以下序列:

创建 Capability billing.charge,版本 v1 → 生成 operation_id 与幂等键 → Provider 返回 request_id=K,副作用已完成 → 在 checkpoint 写入前强制 Worker 崩溃 → 恢复同一个 operation_id → 断言 Provider 收到同一个 K,第二次调用被去重 → 提交 checkpoint,读取结果与第一次一致

预期的拒绝样本应该长这样(这是测试设计,不是本机运行输出):

scenario=cross_operation_replay operation_id=op_20260830_01 provider_calls=1 decision=deduplicated scenario=renamed_operation old_name=billing.charge new_name=billing.charge_v2 decision=blocked reason=operation_identity_changed

你还需要覆盖以下失败:参数哈希变化、结果无法反序列化、租约过期后两个 Worker 同时恢复、取消信号到达但 Provider 已经提交、后端不可用、事件重复投递以及观测上下文丢失。每个失败都应产生结构化事件,而不是只在日志里打印一行异常。

5. 四阶段迁移顺序:先稳定身份,再切默认后端

阶段一:冻结快照与版本

锁定 Pydantic AIv2.36.0、后端适配器版本和序列化库版本。为每类操作保存一份脱敏快照,至少包含operation_id、Capability ID、操作名、Schema 版本、参数摘要、结果摘要和当前 checkpoint。不要把 API Key、文件内容或完整用户输入放进快照。

过关证据是同一份快照在开发、预发布和生产恢复器中得到一致的身份解析结果。若依赖升级让旧快照只能在新解释器中打开,先停止切换默认后端。

阶段二:隔离 DurableOperationBackend

业务层只依赖内部DurableRunner接口,不直接引用 Restate、Lambda 或其他后端的重试 API。适配层负责:

  1. 把模型、工具和事件调用映射到后端;
  2. 传播 operation ID、租约、deadline 和取消信号;
  3. 在提交检查点前记录副作用状态;
  4. 将后端错误映射成可观测的retryable、blocked或compensate。

不要因为某个后端示例使用了“自动重试”就把所有异常都标记为可重试。网络超时、鉴权失败、参数校验失败和 Provider 已执行后的未知状态,处理策略完全不同。

阶段三:运行身份与幂等回归

先不切换线上默认后端,在影子环境回放脱敏快照。至少验证:改名被阻断、旧快照仍能解析、重复事件不增加副作用、部分副作用进入补偿、租约冲突不会双写、取消能传递到工具、观测字段在恢复后仍然相同。

阶段四:按租户或请求切换

稳定入口和旧适配器并行一段时间。通过租户或请求级开关切换,保留快速回滚到旧适配器的路径,但不要回滚已经写入的幂等记录和授权记录。回滚只改变“谁负责调度”,不能让同一个业务键重新变成新键。

6.clai --mcp-config:JSON 文件也可能是命令执行入口

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:p=Path(path).resolve()ifp.stat().st_mode&0o022:raisePermissionError("mcp_config_writable_by_group_or_other")data=json.loads(p.read_text(encoding="utf-8"))servers=data.get("mcpServers")ifnotisinstance(servers,dict)ornotservers:raiseValueError("mcp_servers_missing")forname,iteminservers.items():ifset(item)-{"command","args","env"}:raiseValueError(f"unknown_field:{name}")ifitem.get("command")notinALLOWED_COMMANDS:raisePermissionError(f"command_not_allowed:{name}")ifset(item.get("env",{}))-ALLOWED_ENV:raisePermissionError(f"env_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-mcp@2026.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拒绝子进程启动次数为 0
MCP 配置可被组写入拒绝配置加载在执行前失败
环境变量超出最小集合拒绝不把完整环境传给子进程

其中“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 与审计。第三步才是按租户或请求切换后端,并保留只切适配层的回滚开关。

上线前可以用下面的清单做最后检查:

  1. DurableOperationBackend适配层与业务层已隔离;
  2. operation name、Capability ID、参数 / 结果 Schema 和序列化版本已冻结;
  3. 副作用使用稳定幂等键,Provider 端也能去重或有补偿方案;
  4. 恢复测试覆盖“副作用完成但 checkpoint 未提交”的窗口;
  5. MCP 配置来源、权限、命令 allowlist、最小环境和版本均可回读;
  6. 跨租户、Schema 不匹配、配置拒绝时 Provider / 子进程调用次数为零;
  7. 观测记录 operation ID、attempt、配置版本和拒绝原因,但不泄露秘密;
  8. 回滚不会删除幂等记录,也不会把已执行的外部动作当成未发生。

做到这些,持久执行才不是一句“任务会自动继续”的宣传,而是一条能够解释身份、状态、副作用和恢复结果的证据链。

官方来源

  • Pydantic AI v2.36.0 Release
  • Durable Execution Backends
  • Custom Capabilities
  • Pydantic AI CLI

本文验证边界

  • 官方事实:v2.36.0的 DurableOperationBackend、durable_operation、Capability 稳定 ID 与clai --mcp-config配置形状。
  • 工程判断:幂等键设计、四阶段迁移、MCP allowlist、回归矩阵、审计字段和补偿策略。
  • 未验证项:未在本机安装或运行 Pydantic AI,未连接 Restate / Lambda / Absurd,未启动真实 MCP 子进程,未执行真实 Provider 副作用或故障注入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询