嗯继续吧

回复 joye: 挖了两份文档:docs/a13n-harness/state-and-resume.md 和 docs/a13n-service/operations.md。它的"恢复"是两层,机制完全不同,得拆开看。 第一层是库里的 checkpoint,核心叫 HarnessState。它是一个不可变的 Pydantic 模型,代表"一条独立推进的 thread 的续跑值",里面只有三样东西:schema_version、稳定的 thread_id、公开的 Pydantic AI 消息历史;再加上各 Capability 按命名空间存的、带版本号的 JSON,以及可选的、provider 自己定义的可移植 Environment 状态。明确排除的列表更有意思:模型、工具集、插件、活着的 client、身份认证、policy、凭证、审批、环境挂载定义、宿主的 attempt/租约/队列记录、异步子任务、长期记忆、计费。文档里一句话把这条边界钉死了:State restores data not authority,恢复的是数据,不是权限。 恢复动作本身很朴素:把上次的 state 当 previous_state 传给 run,thread_id 不变,run_id 是新的,bindings、context、EnvironmentRuntime、插件图、用量累加器全部重新构建。要分叉就 state.fork(thread_id=...),注意 fork 会按设计清掉可移植的 Environment 状态。持久化就是 model_dump_json 存下整个信封,不许手改序列化结果;流式跑的时候有 export_state 拿"当前安全边界",但它自己不落盘,选不选这个候选点由调用方判断。 第二层才是重点:它怎么处理"崩在中间"。它没有假装能抹平这个问题,而是把不确定性写进协议。checkpoint 里完全可能出现有 tool call、但结果从未记录的情况——这一步到底跑没跑,没人知道。它的解法是按 run 传 tool_recovery 策略,三档:declared 是默认,只重放那些在源头被显式声明成可重试的工具(@recovery_retryable 装饰器,或给 Tool 打 RECOVERY_RETRY_SAFE_METADATA_KEY),其余未答复的调用一律填一个 unknown result 的 ToolReturnPart 交回模型;never 是全部填 unknown;always 是把当前工具面上还在的调用都重跑一遍。已经记录在案的返回永远权威,已经存在的 unknown result 不会被重开,新生成的调用照常执行。而且声明只说"重复这个操作可以接受",不等于上游会命中同一次操作,provider 层重试和 idempotency 是另一码事。 这块我觉得是整份文档最值得抄的设计:它不靠确定性重放去消掉不确定性,而是承认"这一步可能执行过了",然后让模型在知道这件事的前提下继续决策。 人工介入也归进同一套。遇到要审批或由应用侧执行的工具,run 不是卡住,而是正常结束、status 变成 suspended,返回里带 state、deferred 那封装好的待处理请求、以及 suspend_reason="deferred"。外部动作做完,再用 DeferredToolResume 起一个新 run。校验很严:待处理请求和你给的批结果必须精确对上,参数要重新校验,当前 policy 和 provider 还得放行——之前批过不等于现在就能过。文档甚至专门写了一种容易翻车的场景:外部结果已经被接受、但还没进 native 历史就崩了。因为 Harness 不在 Capability state 里另存一份 deferred 结果,所以 Host 必须把那次 DeferredToolRequests/DeferredToolResults 和 checkpoint 一起留着,用 accepted.remaining(message_history) 判断有没有并进历史;没存的东西它明说不保证找回。 还有一处很克制:失败的结果里可能附带一个归一化过的 state 候选(比如保留完整可见文本和已完成的思考,丢掉没写完的那段),但文档反复强调这只是候选,不是"可以恢复"的结论。选它之前 Host 得先判断某个外部变更是不是已经派发出去了、只是结果没回来,先对账,或者用操作自带的 idempotency 契约,再决定重放。RunCleanupError.outcome 同理。全文到处在声明:不保证 exactly-once。 平台层就是 attempt 加租约。一个 run 由 worker 执行,可以是一次或多次 attempt;worker 崩了,租约过期后另一个 worker 从最后一个 checkpoint 起一个新的 attempt 接着跑,超过 worker.max_attempts 才判失败;优雅关停时 worker 停止认领,留 worker.drain_seconds 做"带 checkpoint 的交接"。后台 expire_leases 扫描专门把过期租约的 attempt 结束掉,好让 run 换台机器继续。权限那条也跟前面对上了:workspace 被归档的话,正在跑的 run 会在下次续期权限时(worker.authority_seconds 内)以 authority_revoked 失败。外部副作用写得很直白——不会自动回滚,也不保证重复执行安全。 等待类的恢复走 HTTP。一个 resume 请求必须一次性精确覆盖那条 waiting run 的所有 pending 项,缺项、ID 不对、类别不对整个请求被拒,没有默认答案也没有部分提交;成功后生成的是后继 run,同一个幂等键重放返回同一个结果;只有线程那个 waiting 的 head 能被 resume,否则 409。交付那边(webhook、身份邮件、子 agent 结果)是至少一次投递,失败指数退避到最长一小时,用完重试次数进死信,并且给了 backlog 和死信的告警指标。 和两条熟路线的差别,我的理解是这样。Temporal 是 event history 重放 workflow 代码,所以强调确定性、副作用要挪进 activity,还有版本兼容的坑;LangGraph 是 checkpointer 按 super-step 存图状态,从最新 checkpoint 续,节点级重跑,人工介入用 interrupt 加 Command(resume=...) 那套。a13n 两条都不走:不重放代码,只恢复数据;权限和绑定每次重建;要不要重放交给每个工具自己声明,剩下的统一变成 unknown result 交给模型。好处是 agent 循环里那些天然不确定的模型调用不用被硬塞进确定性的框;代价是 exactly-once 落到 Host 和产品头上,而且 Host 得自己管住 checkpoint 之外那些"已接受但还没进历史"的输入。它文档里有一张事实归属表,说的就是这个:会话续跑值归 HarnessState 候选,选哪个 checkpoint 和它的出处归 Host,身份/policy/凭证每次重建,attempt/租约/围栏归 Host,完成通知和输出交付归产品。 一句话概括:它不是给 Agent 加了个"崩溃后自动重试",而是把"我不知道这步做没做"变成协议里一个合法的状态。