很多 AI agent 的执行链都不再是同步闭环。OCR、文档解析、异步审批、第三方处理结果、Webhook 通知,这些步骤往往要过几秒、几十秒甚至更久才回来。难点不在“有没有收到回调”,而在“收到以后,系统能不能确定这条结果应该挂回哪一次 Run、哪一个 Step、当前还算不算有效”。
如果没有这层收敛设计,异步回调很容易变成系统里最难排查的一类错误:结果回来了,但挂错上下文、重复入账,或者因为 Run 已结束而悄悄丢失。
建议先结合 AI agent Checkpoint 与断点恢复、AI agent Run Ledger 审计模型、AI agent Worker Lease 与心跳机制 和 AI agent 沙箱环境设计 一起看。
先给结论:异步结果不能当成“新请求”,而要当成“旧 Run 的后续事件”
| 问题 | 正确处理 |
|---|---|
| 回调属于谁 | 用 correlation id 关联原 run |
| Run 还活着吗 | 检查 pending state 与有效期 |
| 回调重复到达怎么办 | 幂等写入与事件去重 |
| Run 已结束怎么办 | 进入 reconcile 或 orphan queue |
如果系统把回调当成一个独立新请求,就会丢掉最关键的上下文。
一、发出异步请求时就要生成 callback contract
更稳的方式是把异步调用发出去之前,先记录一份等待合同:
{
"runId": "run_123",
"stepId": "ocr_parse_01",
"callbackToken": "cb_8f2a",
"expectedEvent": "ocr.completed",
"expiresAt": "2026-05-10T10:10:00Z"
}
这样外部结果回来时,系统不是“猜测这是什么”,而是去匹配一份已经存在的等待记录。
如果链路更复杂,这份 contract 最好再带幂等和恢复边界:
{
"dedupWindowMs": 86400000,
"resumePolicy": "resume_after_reconcile",
"orphanAfterMs": 600000,
"callbackAttemptKey": "cb_8f2a:ocr.completed"
}
这样系统对重复回调、迟到回调和恢复时机的处理会更一致,而不是由各个 handler 临时决定。
二、Pending state 要显式存在,不要靠内存等待
异步等待最容易犯的错,是在 worker 里 await 外部结果,假设线程一直活着。真实系统应该把等待写成状态:
waiting_callbackwaiting_external_approvalcallback_received_pending_reconcile
这会让系统在 worker 重启、Lease 回收或人工查询时,都能明确知道当前 Run 停在什么外部依赖上。
进一步说,回调等待最好也有显式状态迁移:
| 状态 | 含义 | 下一步 |
|---|---|---|
waiting_callback | 已发请求,等待结果 | 等待或超时挂起 |
callback_validating | 已收到回调,正在验签和匹配 | 通过后进入 reconcile |
callback_reconciled | 已完成挂回和落账 | 恢复执行或转人工 |
orphaned_callback | 找不到有效 run | 进入 orphan queue |
这张表的价值在于,系统不只知道“回调来过了”,还知道它现在处于哪一个收敛阶段。
三、回调匹配至少要验证 4 个条件
回调不是拿到 token 就能直接继续执行。更安全的验证通常包括:
| 检查项 | 目的 |
|---|---|
| callbackToken / correlationId | 找对原 Run |
| event type | 防止把错误结果挂错步骤 |
| signature / source verification | 防止伪造 Webhook |
| expiresAt / run status | 防止过期回调继续推进旧流程 |
只有这几层都成立,系统才应该继续进入后续状态。
四、回调收敛不是简单更新状态,还要做 reconcile
一个更完整的回调处理流程通常是:
- 收到 Webhook
- 校验来源与 correlation id
- 读取等待中的 run / step
- 生成 callback artifact 或结果快照
- 写入 ledger
- 决定继续执行、转人工还是进入 orphan queue
“reconcile” 这个动作很重要,因为回调带回来的不一定是可以直接继续的最终结果,也可能只是一个需要二次解析的中间事实。
很多团队会在这里再补一份 reconciliation record,明确这次回调究竟影响了哪一次恢复:
{
"runId": "run_123",
"callbackToken": "cb_8f2a",
"matchedStepId": "ocr_parse_01",
"reconcileOutcome": "resume",
"artifactCreated": "callback_result_07"
}
这会比只写一条“Webhook 处理成功”有价值得多,因为它能把异步结果真正挂回具体 run 和具体产物。
五、重复回调和孤儿回调都要单独处理
现实里常见两类脏事件:
- 重复回调:第三方因为网络问题重复推送
- 孤儿回调:Run 已关闭,结果才回来
这两类都不应该直接报错丢弃。一个更健康的处理方式是:
- 重复回调:命中幂等键,记录 duplicate received 后忽略副作用
- 孤儿回调:进入 orphan queue,等待人工对账或补挂
否则排查时你只会看到“系统偶尔没反应”,却不知道结果其实已经来过。
六、上线后要看 callback 链路完整率,而不是只看 Webhook 成功率
建议至少记录:
| 指标 | 用途 |
|---|---|
| callback matched ratio | 看有多少回调能正确挂回原 Run |
| duplicate callback rate | 看外部系统是否经常重复推送 |
| orphan callback count | 看是否存在 Run 关闭后的迟到结果 |
| callback replay blocked ratio | 看重复回调是否真的被幂等挡住 |
| expired callback rejected count | 看迟到结果是否常越过有效期 |
| callback to resume latency | 看回调回来后多久真正继续执行 |
Webhook 200 只能说明你收到了请求,不代表系统真的把它接回了正确流程。
七、失败案例:OCR 结果回来了,但被挂到最新 Run 上
某个文档解析 agent 用 documentId 作为唯一关联键。用户短时间内对同一文档发起了两次处理请求,第二次 Run 启动后,第一次 OCR 结果才返回。系统按 documentId 把结果挂到了最新 Run,导致后续校验全部基于错误版本继续执行。
修复后,团队把回调 contract 改成 runId + stepId + callbackToken 三重关联,并为迟到结果单独进入 reconcile 队列,问题才真正消失。
八、Async Callback 与 Webhook 收敛 Checklist
- 发出异步请求时,是否先写 callback contract
- Run 是否显式进入 waiting callback 状态,而不是靠内存等待
- callback contract 是否包含 dedupWindow、resumePolicy 和 orphanAfter
- 回调是否校验 token、event type、signature 和有效期
- 是否定义 waiting / validating / reconciled / orphaned 的状态流
- 收敛流程是否包含 reconcile,而不是直接改状态
- 是否为重复回调与孤儿回调设计单独处理路径
- 是否落 reconciliation record 而不是只记“处理成功”
- 是否监控 matched ratio、orphan count 和 resume latency
- 审计时能否把回调结果还原到原始 Run 和 Step
结语
AI agent 的异步回调问题,从来不只是“Webhook 能不能收得到”,而是结果回来以后,系统还能不能把它准确、安全地挂回原来的执行链。只有 callback contract、pending state 和 reconcile 共同成立,异步依赖才不会把运行系统撕成两半。
延伸阅读:


