引子:Agent 第一次改写真实数据,问题就换了性质

过去两年关于 Agent 的讨论,大部分时间花在"它能做什么"上:读文档、查库、写报告、生成 SQL、调几个 API。这些场景的共同点是,Agent 的一次失败最多让答案变差,用户重问一次或者人工改一下就恢复了。这类系统我们暂且叫它"建议型 Agent",它的正确性标准是"输出有没有参考价值"。

一旦 Agent 拥有写权限,性质就变了。它可以把订单状态从"待发货"改成"已发货",可以把客户的钱从 A 账户划到 B 账户,可以给两万收件人各发一封邮件,可以在合同上留下一个数字。输出错了可以重写,状态改错了就是现实里多了一次发货、一次划款、一次对外承诺。系统从"生成内容的工具"变成了"对外部世界有副作用的执行者",这时候要回答的问题也不再是模型聪不聪明,而是权限怎么划、事务怎么管、出事怎么追责。

这也是很多团队踩坑的节奏:Agent 读接口做得很漂亮,接上写接口之后第一版能跑,第二版开始出事故,出事故之后发现根本没有回滚手段——因为下游是第三方 SaaS,人家不提供事务,也不提供撤销接口。

一个只读过数据的 Agent 出错,最坏结果是浪费时间。一个写过数据的 Agent 出错,最坏结果是无法撤回的外部事实。

一、先做危险分级:不是所有写操作都一样不可逆

很多团队在"要不要给 Agent 写权限"这个问题上卡住,是因为把它当成一个是非题。其实正确的问法是:这个写操作有多难撤回。把工具按可逆程度分成四档,治理手段完全不同,混在一起谈必然得出"要么全禁,要么全放"这种无用结论。

档位典型动作出错后果可行的治理手段
可原地改写改备注、改标签、改草稿标题新值覆盖旧值,损失有限写前快照 + 可回滚审计
逻辑可撤销取消订单、撤销授权、退款有反向操作,但可能已被下游感知补偿事务 + 撤销审计
财务不可逆付款、划账、退款到外部卡钱已经动了,只能走对账或申诉强制人工确认 + 金额阈值 + 双人复核
对外不可逆发邮件/短信、Webhook 通知、提交监管申报外部世界已收到,无法假装没发生出站网关 + 内容预审 + 全量留痕

这张表的价值在于把"危险"从一个形容词变成一个可以落到代码上的分类变量。分类变量决定了三件事:这个动作走什么审批路径、失败后走什么补偿路径、日志要留什么粒度的证据。后面文章里讲的幂等键、人工确认点、审计字段,都是围绕这张表展开的。

需要强调的是,不可逆性不是静态属性。一个"给客户发一封通知邮件"的工具,看起来只是对外通知,但它内嵌的业务含义可能是"您的订单已发货"——这句话在电商语境下是要约与承诺的边界,误发会引发纠纷和平台处罚。所以真实的做法是按"业务语义"而不是按"HTTP 动词"来分级:同样是 POST,POST /draft 和 POST /shipment/confirm 的风险差了两个数量级。

二、为什么"调 API"在 Agent 场景里突然变难

传统集成代码面对第三方 API 时,是有成熟套路的:你知道请求会失败,知道可能超时,知道对方有重试,知道事务边界在自己这边。你写一个 service 层,处理好超时和重试,任务就算完成。开发者心智模型里,一个 API 调用是一次同步的、可判断成败的函数调用。

Agent 把这个假设砸掉了,有三个具体的转变:

  • 调用由模型发起,参数由模型生成。传统集成里参数来自已验证的业务代码,Agent 这边的参数是一段自然语言经过模型解析后的产物,字段取错、量纲搞错、单位不匹配的失败率天然更高,而且失败之前没有任何静态检查能拦住。
  • 调用是循环的一部分。传统集成里"这一步失败"就抛异常终止,Agent 里"这一步失败"可能会触发重试、换参数、或者换一个工具再来一次。同一个业务意图被表达成多次物理调用,是常态而不是异常。
  • 人在回路里,而且不在关键路径上。传统流程里审批是流程图上的一个节点,Agent 流程里审批可能被安排在中间,也可能被模型"认为已经批准了"。人工确认点的位置选择因此变成一个真问题,而不是走个审批流就完事。

这三条转变各自对应后面的一组设计:参数问题需要在执行前做业务级校验并把校验失败作为一种"信息"返回给模型而不是异常;多次物理调用要求幂等或者去重;人的介入需要被设计成带签名的凭证而不是一次对话。

三、本文的路线

下面按"先划边界、再谈机制、最后谈追责"的顺序展开:

  1. 权限边界为什么必须按读/写/跨租户三个维度切,而不是按功能切;OAuth scope 粒度不由你决定的时候怎么办。
  2. 幂等键在别人家的 API 上不生效时,有哪些替代手段,以及哪些替代手段反而会引入新问题。
  3. 跨 SaaS 的长事务为什么只剩 Saga 一条路,补偿的强弱怎么分级,以及"已调用但没确认"的悬挂状态怎么处理。
  4. 两阶段提交在 Agent 场景下真正能落地的三种形态,以及它的失效条件。
  5. 人工确认点该插在哪些位置,确认时该给人看什么,以及怎么避免确认疲劳和 TOCTOU。
  6. 审计留痕为了事后追责要记什么,为什么写前快照和"当时的系统状态"比操作结果更重要。

全文的核心判断是:接入真实业务系统的难点不在"能不能调通",而在"出错了你能不能说得清楚、撤得回来、追得到人"。这三个能力决定了一个 Agent 集成是从玩具进到生产的门槛。

四、权限边界:按读、写、跨租户三个维度切,而不是按功能切

功能维度授权为什么不够

最常见的做法是按"功能"授权:给 Agent 一个 orders 工具能查订单也能改订单,给一个 refunds 工具能发起退款。这在直觉上很合理,但有三个漏洞,而且三个漏洞都会在生产上出现。

第一个漏洞是爆炸半径。一个"改订单"的工具,实际能改的字段可能包括收货地址、收货人、联系电话、备注、状态、数量。如果权限只区分"能改订单"和"不能改订单",那么一次模型幻觉就能把货发到错误的地址,而系统层面没有任何机制拦它。要拦,就必须把工具拆细到"改地址"和"改状态"是两个不同的工具、两套不同的授权。

第二个漏洞是读写混用。绝大多数 SaaS 的 OAuth 集成里,读一个接口和写一个接口在服务端往往共享同一个 scope,甚至共享同一套对象级权限。这意味着一旦你给了写权限,Agent 顺手批量读取的能力也被放开了,而读取动作在多数安全评估里危害等级不低于写入——它可以直接拖走全量客户数据。

第三个漏洞是跨租户。多客户场景下,权限名是"能操作订单",但真正的边界是"能操作哪个租户下的订单"。这两个维度必须同时检查,缺一个就出事。检查的位置应该在服务端凭证解析之后、工具执行之前,而不是让模型自己在参数里填 tenant_id 然后祈祷它填对了。

三维授权模型

把上面三个漏洞合起来,一个可落地的授权模型至少要三个正交维度:动作(读 / 写 / 删除 / 导出)、对象范围(哪些资源类型、哪些字段、哪些租户)、风险等级(对应第一节那张不可逆性分级表)。三者取交集才放行。

关键设计判断是:这三个维度必须在服务端或者独立的授权层判定,绝不能下放给模型或者让模型通过参数表达。模型可以提出"我要把订单 12345 的状态改成已发货"这个请求,但它不能决定"我有权改订单 12345"。前者是意图,后者是授权,混在一起的那一刻,权限系统就已经失效了。

工程上的做法是引入一层能力票据(capability ticket):Agent 规划阶段产出的不是一个直接的 API 调用,而是一张受限的、签名的、只能做一类事、只能碰一组对象的票据。工具执行层只接受票据,不接受裸参数。票据本身可以被检查、被过期、被撤销,可以被人看一眼就知道这次操作允许什么。

# 能力票据:把"意图"和"授权"拆成两件事
# 模型只能产出 grant 里的 action / resource / constraints,
# 至于"能不能"由这里的 policy 层说了算。

import time
import hmac
import hashlib
import json
from dataclasses import dataclass, field

# 不可逆性分级,与文章第一节的表格对应
RISK = {
    "draft.write": 1,        # 可原地改写
    "order.status.write": 2, # 逻辑可撤销
    "refund.create": 3,      # 财务不可逆
    "notice.send": 3,        # 对外不可逆
}

# 超过这个等级必须人工确认
CONFIRM_THRESHOLD = 3


@dataclass
class Grant:
    action: str                       # 动作,如 order.status.write
    resource_type: str                # 资源类型,如 order
    resource_ids: list                # 允许触碰的具体对象
    fields: list                      # 允许修改的字段白名单
    tenant_id: str                    # 租户,由凭证解析得出,不由模型提供
    max_calls: int = 1                # 票面额度,用完即焚
    constraints: dict = field(default_factory=dict)
    issued_at: float = 0.0
    expires_at: float = 0.0
    nonce: str = ""


class PolicyError(Exception):
    pass


class CapabilityBroker:
    """唯一的授权出口。工具层只认票据,不认模型给的参数。"""

    def __init__(self, secret: bytes):
        self.secret = secret
        self.used = set()

    def issue(self, *, action, resource_type, resource_ids, fields,
              tenant_id, ttl=120, max_calls=1, constraints=None):
        if action not in RISK:
            raise PolicyError("未登记的动作不允许发放票据: " + action)
        now = time.time()
        grant = Grant(
            action=action,
            resource_type=resource_type,
            resource_ids=list(resource_ids),
            fields=list(fields),
            tenant_id=tenant_id,
            max_calls=max_calls,
            constraints=constraints or {},
            issued_at=now,
            expires_at=now + ttl,
            nonce=hashlib.sha256(
                (str(now) + resource_type).encode()).hexdigest()[:16],
        )
        return grant, self._sign(grant)

    def redeem(self, grant, signature, *, args, caller_tenant):
        self._verify(grant, signature)
        if time.time() > grant.expires_at:
            raise PolicyError("票据已过期")
        if grant.nonce in self.used:
            raise PolicyError("票据已被使用,拒绝重放")
        if caller_tenant != grant.tenant_id:
            raise PolicyError("跨租户访问被拒绝")
        if grant.action not in args:
            raise PolicyError("请求动作与票面不符")
        extra = set(args[grant.action].keys()) - set(grant.fields)
        if extra:
            raise PolicyError("超出字段白名单: " + ",".join(sorted(extra)))
        if self.used_count(grant) >= grant.max_calls:
            raise PolicyError("票面额度耗尽")
        self.used.add(grant.nonce)
        return grant.action, args[grant.action]

    def needs_human(self, grant):
        return RISK.get(grant.action, 99) >= CONFIRM_THRESHOLD

    def _sign(self, grant):
        raw = json.dumps(grant.__dict__, sort_keys=True, default=str)
        return hmac.new(self.secret, raw.encode(), hashlib.sha256).hexdigest()

    def _verify(self, grant, signature):
        if not hmac.compare_digest(self._sign(grant), signature):
            raise PolicyError("票据签名校验失败")

    def used_count(self, grant):
        return 1 if grant.nonce in self.used else 0

OAuth scope 粒度不由你决定

上面这套模型很干净,但现实里有一层硬约束:凭证的粒度是第三方 SaaS 定的。你想按动作和字段切分权限,对方给你的可能就是一个 orders.write,读写不分、字段不分、租户不分。这时候有三种务实的做法。

  • 用凭证类型区分,不试图细粒度授权。最简单有效的办法是同时持有读凭证和写凭证两套 token,工具层按动作选对应的那套去调。这样即使对方 scope 很粗,你在自己的进程里也做到了读写分离,至少能保证"读路径被攻破"时拿不到写凭证。
  • 在自己这侧做影子写。把写操作先落到本地的影子表,调用第三方成功之后再由一个单点组件把状态"发布"过去。这个本地表是你的,你当然可以按字段校验、可以记录前后值、可以撤回未发布的写。代价是多一跳延迟和一次状态同步。
  • 把粗粒度授权的风险显式写下来并设闸门。明确记录"这个工具一旦被调用就等于给了全量写权限",然后为该工具单独配置确认点、速率上限和告警。这是最不优雅但最诚实的做法,很多生产系统就停在这里。

不要做的事是:假装 scope 足够细,然后在文档里写"我们做了最小权限"。权限模型的真实边界写在代码里,不写在文档里,而代码里的边界是对方给的那个粗粒度 scope。

读权限同样是攻击面

把注意力全放在写操作上是另一个常见偏差。Agent 的读操作有两个特有风险:一是批量拉取,模型可能在一个循环里连续调用读接口把整张表拖走,而每次调用单独看都合法;二是读到的敏感数据进入上下文,而上下文会被送去模型供应商,敏感数据一旦出域就超出了你的控制范围。

对第一个风险,闸门应该加在读侧:单次任务的读取量上限、单对象的行数上限、以及"连续读同一资源类型超过 N 次就要求解释"。对第二个风险,合理的做法是给工具返回结果做字段级脱敏,模型看到的是"手机号尾号 4321"而不是完整手机号,需要完整值时由一个单独的、带审批的取数动作提供。脱敏要在工具层做,不要靠 prompt 里写"请不要输出手机号",那是把访问控制交给了概率模型。

凡是"不希望它出现在模型上下文里"的数据,就应该在工具层就被裁掉,而不是指望模型自觉。模型是被动接收方,它没有能力执行数据保护策略。

五、幂等键在别人家的 API 上不生效时

幂等性的三个来源

先厘清一件事:幂等不是你的客户端行为,而是服务端的契约。对方支持"传同一个 Idempotency-Key 就只生效一次",你才有幂等;对方不认这个头,你传一百遍也没用。工程上能依赖的幂等来源只有三个,实践中大多数第三方 SaaS 只提供前两个中的半个。

来源机制典型可用性
客户端提供请求带幂等键,服务端有唯一约束只有少数支付类 API 提供,且键的作用域和过期时间各异
业务天然键由业务唯一性保证,例如同一订单号只能有一条发货记录最常见,但要求你去发现它
你的影子层在你自己的存储里做唯一约束,先落库再外发永远可用,但需要额外一次写入

没有第一种来源时,绝大多数团队的默认反应是把同一个请求重发三遍然后祈祷。这是最糟的选项,因为它在最好的情况下浪费三次配额,在最坏的情况下产生三条真实记录。所以真正的任务是:如何在没有服务端幂等支持的情况下,让重试变得安全。

手段一:客户端幂等票据,把唯一性建在自己这边

这是最通用、也最值得优先实现的方案。它的核心不是给请求加参数,而是在你自己这边先建立一个"这件事我打算做"的唯一记录,再去调用外部。外部调用重复发生也没关系,因为外部返回的结果会被你的台账吸收。

实现要点有三个。第一个是台账必须先落盘再调用,顺序反了就会出现"调用成功了但台账没写上"的重放窗口。第二个是唯一约束必须在数据库层,不能靠应用层先查后插——并发下两个重试会同时查到"不存在"。第三个是状态必须是显式的三态而不是布尔:pending / succeeded / unknown。只存布尔值是最常见的错误,因为你无法区分"没做"和"做了但结果丢了",而这两种情况的处置方式完全相反。

# 幂等台账:外部 API 不认幂等键时,把去重责任收到自己这边。
# 关键点是三态而不是布尔 —— unknown 是最容易被忽略的一态。

import sqlite3
import time
import json
import uuid

PENDING, SUCCEEDED, UNKNOWN, FAILED = "pending", "succeeded", "unknown", "failed"


class DedupeLedger:
    def __init__(self, path):
        self.db = sqlite3.connect(path, isolation_level=None)
        self.db.execute("PRAGMA journal_mode=WAL")
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS op (
                key         TEXT PRIMARY KEY,
                op_type     TEXT NOT NULL,
                natural_key TEXT NOT NULL,
                tenant_id   TEXT NOT NULL,
                state       TEXT NOT NULL,
                request     TEXT NOT NULL,
                response    TEXT,
                external_id TEXT,
                created_at  REAL NOT NULL,
                updated_at  REAL NOT NULL
            )
        """)
        # 天然键兜底:即使外部调用重复发生,业务上也不该出现第二条
        self.db.execute("""
            CREATE UNIQUE INDEX IF NOT EXISTS op_natural
            ON op (op_type, tenant_id, natural_key)
        """)

    def reserve(self, key, op_type, natural_key, tenant_id, request):
        """先落盘再外发。冲突时返回已存在的记录,而不是报错。"""
        now = time.time()
        try:
            self.db.execute(
                "INSERT INTO op (key, op_type, natural_key, tenant_id, state,"
                " request, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
                (key, op_type, natural_key, tenant_id, PENDING,
                 json.dumps(request, sort_keys=True), now, now))
            return {"state": PENDING, "key": key, "external_id": None}
        except sqlite3.IntegrityError:
            row = self.db.execute(
                "SELECT state, response, external_id FROM op WHERE key=?",
                (key,)).fetchone()
            # 已有记录:重放是安全的,把上次的结果交回去
            return {"state": row[0], "key": key, "external_id": row[2],
                    "response": row[1]}

    def mark(self, key, state, response=None, external_id=None):
        now = time.time()
        if state == UNKNOWN:
            # 超时或连接中断:既不能判成功也不能判失败,留给对账
            self.db.execute(
                "UPDATE op SET state=?, response=?, updated_at=? WHERE key=?",
                (state, json.dumps(response or {}) if response else None, now, key))
            return
        self.db.execute(
            "UPDATE op SET state=?, response=?, external_id=?, updated_at=?"
            " WHERE key=?",
            (state, json.dumps(response or {}, ensure_ascii=False),
             external_id, now, key))

    def recheck_after_timeout(self, key, probe):
        """对账用的探测:用一个只读接口确认那次调用到底生效没有。"""
        row = self.db.execute(
            "SELECT state, external_id, request FROM op WHERE key=?",
            (key,)).fetchone()
        if row[0] == UNKNOWN:
            found = probe(json.loads(row[2]))
            if found:
                self.mark(key, SUCCEEDED, external_id=found)
                return SUCCEEDED
        return row[0]

    def pending_older_than(self, seconds):
        """悬挂扫描:超过窗口还没结论的,必须有人工介入或对账。"""
        cutoff = time.time() - seconds
        return self.db.execute(
            "SELECT key, op_type, natural_key, state FROM op"
            " WHERE state IN (?,?) AND updated_at < ?",
            (PENDING, UNKNOWN, cutoff)).fetchall()

手段二:天然键去重,把"同一件事"表达成唯一事实

很多业务里根本不需要幂等键,因为业务本身有唯一性:同一个订单只能有一条发货记录,同一个结算周期只能有一张对账单,同一个手机号每天只能收到一次验证码。找到这个天然键,去重就可以在查询侧做,而且比在写入侧做更稳。

天然键的发现方法是问自己一个问题:如果这次写操作真的执行了两次,数据库里会出现什么不合理的数据?答案通常就是天然键。这个问法比翻 API 文档找 Idempotency-Key 更有效,因为后者经常根本不存在。

手段三:预检查,以及它为什么会翻车

最直觉的做法是"写之前先查一下,已经存在就不写"。这招在很多场景下有效,但有三个坑需要提前知道。

  • TOCTOU。先查后写之间存在时间窗,两个并发的执行(比如用户重试了一次,或者另一条链路在同时处理)会同时通过检查,然后都去写。预检查只降低了重复概率,没有消除。
  • 成本翻倍。每次写操作前多一次读,接口调用量直接翻倍。对有速率限制的第三方 API,这可能让你在批量任务中频繁撞限流,反而比偶发重复更糟。
  • 把"没有就写"的假设编码进了业务逻辑。如果这个"没有"本身是业务判断的一部分(比如"这个客户是否已经有过退款记录"决定的是能不能退,而不只是要不要重复退),那么预检查实际上是在业务层引入了状态机,复杂度不比直接做幂等低。

所以判断标准是:预检查适合用来做"如果重复会造成业务错误"的不变量校验,不适合用来做幂等的唯一实现。前者是业务需要,后者是可靠性需要,两者可以并存但别混用同一段代码。

什么时候该放弃幂等,转而限制频率

有一类操作既难以幂等化(每次调用都必须真实发生一次,比如递增计数器、发送随机验证码),也不值得为它建台账。正确的处理不是死磕幂等,而是承认它天然不幂等,然后把它收进"至多一次"的严格配额里:每个任务周期内这类操作的成功次数硬性封顶,撞到上限就停下来等人工,而不是让模型自己决定要不要再试一次。

这类操作必须在能力票据里单独标记,比如 at_most_once=true,票面额度就是 1,消耗后不重发,只进人工队列。它的好处是语义诚实:你在系统里明确记录了"这个操作不保证幂等,我们靠配额和人工兜底",而不是留一个"理论上不会重复"的注释。

在系统里写"应该不会重复"和写"最多执行一次,重复了走人工",是两种完全不同的工程状态。前者是许愿,后者是可验证的契约。

六、长事务与部分失败:跨 SaaS 只剩 Saga 一条路

为什么 XA 在这里根本不是一个选项

两阶段提交的经典形态要求所有参与方都实现 prepare 和 commit 两个协议方法,协调者才有资格提议一个全局事务。但当参与方是第三方 SaaS 的 REST API 时,这个前提不成立:对方不会为你的事务实现 prepare,你也无法强制它在崩溃后一直持有锁等你的决定。

所以在 Agent 接真实业务系统这个场景里,"要不要用两阶段提交"不是一个技术选型问题,而是一个前提是否成立的问题。跨出你控制的进程边界,分布式事务就只剩下两种形态:要么退化成补偿(最终一致),要么放弃全局原子性、只保证每个本地步骤的原子性。中间没有第三条路。

需要补充的是:本地步骤的原子性往往也是没有的。第三方 API 的"修改订单状态"可能是"改主表 + 写操作日志 + 触发通知"三个动作,你的单次 HTTP 调用看不到内部过程,拿到的是它们的最终状态。这时候你能依赖的只有它们给的响应和后续查询,不是它们的内部事务。

补偿不是回滚:先把强弱分级

团队最常见的误解是把 Saga 补偿理解成数据库回滚。数据库回滚有原子性和隔离性保证,补偿没有。补偿是一个用新的正向操作去抵消旧的正向操作的过程,它会失败、会部分失败、还可能被下游观察到中间态。所以补偿必须先做强弱分级,再决定要不要依赖它。

补偿强度形态例子需要的前置条件
强补偿撤销接口在下游没有副作用撤销一张还没被使用的优惠券下游保证撤销不触发通知、不留可见痕迹
弱补偿撤销会留下痕迹,或撤销本身被异步观测取消订单会发出"订单取消"通知接受痕迹,或在事前把痕迹也算进预期
无补偿根本不存在反向操作已提交的银行转账、已发送的对外邮件只能事前拦截,不能事后处理
假补偿看起来撤销了,实际只是在自己系统改了状态本地把订单标成已取消,下游仍认为已发货必须有对账兜底,且明确告知用户

最后一行是最危险的,因为它在系统里长得像成功的补偿。做这个判断的标准很简单:补偿动作之后,去下游查一次,状态真的变了吗?没变就是假补偿。假补偿不是不能做,但必须让用户知道"我们这边显示已取消,对方系统可能还显示已发货",而不是在界面上画一个统一的成功态。

已调用但没确认:悬挂是常态不是异常

Agent 场景的长事务有一个传统分布式事务里不算突出的新问题:调用可能已经到达对方,但响应在网络层丢了。传统系统里这种情况通常表现为用户看到超时然后重试,量级不大;Agent 场景里,一个循环里的多次重试会把这种"结果未知"的调用放大,而且模型往往在收到超时后立刻改参数再来一次。

这就产生了一类必须在数据模型里显式表达的状态:悬挂(in-doubt)。一次写操作在结果未知时,既不能记成功也不能记失败,必须停留在悬挂态,并且进入一个周期性对账流程。忽略悬挂态的做法会直接导致两个后果之一:要么把未知当成成功,用户看到"已完成"但对方没数据;要么把未知当成失败而重试,对方产生重复记录。

悬挂的处置顺序应该是:先尝试只读探测(对方是否提供查询接口),探测不出来就转人工,绝不盲目重发。人工那一步需要的材料是:调用的完整参数、发起时间、当时返回的错误特征、以及当时拿到的响应体片段。这几项如果没记,人工也判不了。

业务级补偿与技术级补偿

另一组必须分清的区分是补偿的层次。技术级补偿是"撤销我刚才做的那次写",动作固定、可机械执行;业务级补偿是"让业务回到合法状态",动作取决于业务上下文,可能与原操作完全无关。

举例:Agent 帮客户多发了一箱货。技术级补偿是"取消这一行订单项",业务级补偿可能是"取消订单项并按客户等级触发一次客服跟进"。前者机械可靠但可能留下业务上的尴尬状态,后者更贴近真实需求但需要模型或规则来生成——而这恰恰是危险的地方:让模型生成补偿动作,等于把不可逆操作交给了概率组件。

务实的设计是:技术级补偿由确定性代码执行,模型只负责"提出补偿意图"(要取消哪一项、理由是什么),补偿的落地由规则化的映射表决定。模型进不来这一层,责任才清晰。

实现:Saga 台账与悬挂扫描

# Saga 编排器:只负责"记录做了什么"和"决定下一步",
# 不负责回滚 —— 跨系统没有回滚,只有补偿。

import time
import json
from dataclasses import dataclass, field, asdict


class StepState:
    PENDING = "pending"
    DONE = "done"
    FAILED = "failed"
    COMPENSATED = "compensated"
    COMPENSATE_FAILED = "compensate_failed"
    IN_DOUBT = "in_doubt"


# 每个步骤必须预先声明补偿能力,声明不了就不该进 Saga
STEP_CONTRACT = {
    "create_ticket":   {"compensate": "cancel_ticket",   "strength": "strong"},
    "reserve_stock":   {"compensate": "release_stock",   "strength": "strong"},
    "charge_card":     {"compensate": None,              "strength": "none"},
    "send_notice":     {"compensate": None,              "strength": "none"},
    "assign_courier":  {"compensate": "unassign_courier","strength": "weak"},
}


@dataclass
class Step:
    name: str
    state: str = StepState.PENDING
    request: dict = field(default_factory=dict)
    response: dict = field(default_factory=dict)
    attempts: int = 0
    last_error: str = ""
    updated_at: float = 0.0


class Saga:
    def __init__(self, saga_id, steps):
        self.id = saga_id
        self.steps = [Step(name=n) for n in steps]
        self.created_at = time.time()

    def snapshot(self):
        return {
            "saga_id": self.id,
            "created_at": self.created_at,
            "steps": [asdict(s) for s in self.steps],
        }

    def plan_compensation(self):
        """逆序补偿 —— 已完成的步骤按倒序一个个撤,撤一个确认一个。"""
        plan = []
        for s in reversed(self.steps):
            if s.state != StepState.DONE:
                continue
            contract = STEP_CONTRACT[s.name]
            if contract["compensate"] is None:
                # 无补偿:不能悄悄跳过,必须显式记录为"需要人工"
                plan.append({"step": s.name, "action": None,
                             "action_required": "manual",
                             "reason": "该动作无反向接口"})
                continue
            plan.append({"step": s.name,
                         "action": contract["compensate"],
                         "action_required": "auto",
                         "strength": contract["strength"]})
        return plan

    def record(self, name, state, response=None, error=""):
        s = self._get(name)
        s.state = state
        s.attempts += 1
        s.updated_at = time.time()
        if response is not None:
            s.response = response
        s.last_error = error
        return s

    def in_doubt(self):
        return [s.name for s in self.steps
                if s.state in (StepState.IN_DOUBT, StepState.PENDING)
                and time.time() - s.created_at > 300]

    def _get(self, name):
        for s in self.steps:
            if s.name == name:
                return s
        raise KeyError(name)


def classify_outcome(response, transport_error):
    """把一次调用的结果归成三态。这一步是整个 Saga 的地基。"""
    if transport_error is None:
        return StepState.DONE, response
    if getattr(transport_error, "definitely_not_sent", False):
        # 连接都没建起来,对方一定没收到,可以安全重试
        return StepState.PENDING, None
    if getattr(transport_error, "server_replied", False):
        # 拿到了错误响应:这是确定性的失败
        return StepState.FAILED, getattr(transport_error, "body", None)
    # 发出去了但没等到回音 —— 最难处理的一类
    return StepState.IN_DOUBT, None

这段实现里有一个容易被跳过的设计判断:plan_compensation 对没有反向接口的步骤不返回跳过,而是返回一条"需要人工"的显式记录。很多实现在这里直接 continue,结果是一笔失败的交易在系统里显示成"部分完成",而那几个无法补偿的步骤既没被撤销也没被人知道。显式列出它们,才有人去处理。

七、两阶段提交在 Agent 场景下真正能落地的三种形态

前面说了跨系统没有经典 2PC。但"先准备、再提交"这个意图其实极其有价值,它能消掉一整类"提交到一半"的问题。所以在 Agent 接真实系统这个场景里,值得做的是把 2PC 的语义降级重建成三种可实现的形态,并且清楚每种形态的失效条件。

形态一:两阶段确认(准备态 + 提交态)

前提是对方的 API 本身提供"创建草稿"和"确认生效"两个动作。财务和工单类 SaaS 经常有这个设计:先 POST 一个返回草稿号,再 PUT 确认。如果有,这是最干净的选择,因为 prepare 阶段对方已经把资源占住了。

这个形态的关键工程点是协调者的崩溃恢复。协调者必须先持久化"我准备提交 X、对方草稿号是 D"这条记录,再发起 confirm。如果顺序反了——先 confirm 后落库——进程在中间挂掉,你就永远不知道该确认哪个草稿。

形态二:影子表 + 原子发布

对方没有草稿机制时,你可以在自己这边造一个。所有写先落到本地影子表,状态是 staged,用户可以看、可以改、可以撤。确认之后,由一个发布器把影子行翻译成对方的 API 调用,成功后把影子行标记为 published 并写入对方返回的 ID。

这个形态把"是否提交"的决定完全收在你手里,因此确认点可以是任意一个 UI 动作,甚至可以支持"预演发布"(只算参数不真调)。它的代价是双写问题和一致性延迟:影子表说成功,对方可能失败。应对办法是给影子行一个 published 状态和一个 sync_error 状态,以及一个把两者对平的定时任务。

形态三:写前快照 + 单向提交

最弱但最通用的形态。在任何写操作之前,把目标对象的完整当前状态抓下来存好;写操作本身是单次的、不可分割的调用。事后如果发现问题,用快照恢复。这个形态不追求原子性,只追求可追回性。

它的适用面比前两个大得多,因为前两个都要求对方配合。缺点是恢复动作本身又是写操作,理论上还会失败;所以快照必须包含足够的信息让恢复是确定性的(比如金额、状态、版本号都要存,而不是只存 diff)。

失效条件:什么情况下两阶段会散架

把三种形态的失效条件放在一起看,会发现它们最终都收敛到同一组根因上。

失效条件现象根因缓解手段
prepare 之后协调者崩溃对方资源被永久占用,业务看不到草稿悬挂事务prepare 必带 TTL,TTL 到期对方自动释放;协调者有恢复扫描
commit 请求丢失对方已提交,你这边记为失败,于是重复提交不确定结果被当成失败commit 阶段必须先查后重试,且台账区分 unknown 态
人工确认跨越两阶段用户确认时看到的是 5 分钟前的数据,实际执行时数据已变TOCTOU确认票据绑定对象版本号,执行前重新校验,不一致则作废重来
跨租户数据进入同一个 sagaA 租户的确认通过了 B 租户的提交票据未绑定租户能力票据强制带 tenant_id,签名覆盖它
草稿语义对方不保证创建草稿其实已经触发了下游通知"准备"不是真的准备接入时实测一次,不要相信文档里"draft"这个词
影子表无限增长历史 staged 行的语义和当前行冲突影子表没做生命周期管理staged 行按 TTL 清理,清理前必须已 published 或已撤销
发布器单点发布器挂了,所有 staged 行堆积没有队列化发布走持久化队列,可重放,幂等键绑影子行 ID
版本号由模型提供乐观锁形同虚设并发控制参数不可信版本号由读取工具返回并原样回传,工具层校验来源

这张表里最值得强调的是第二行和第三行。第二行是"重复提交"的根源,而它的荒谬之处在于:明明已经做对了一半(台账有 unknown 态),却因为代码里写的是 if error: retry 而毁掉了这个设计。第三行是 Agent 场景特有的,因为传统流程里确认和执行之间是毫秒级,Agent 场景里可能隔着几分钟甚至一次人工排队。

什么时候不要用两阶段

两阶段是有成本的:多一次往返、多一份状态、多一套恢复逻辑、多一个需要维护的不变量。如果操作本身是低风险的、可以补偿的、失败影响面小的,加两阶段就是净亏损。

一个可操作的判据是:如果这次写操作失败了,你能不能接受它处于"部分生效"的状态,并且用户能在界面上理解这个状态?能接受,就不要两阶段,直接做,简单可靠。不能接受,尤其是涉及钱和对外承诺的,再考虑两阶段或者等价的强确认机制。

两阶段解决的是"提交到一半"的问题,不是"提交错了"的问题。提交错了要靠校验、确认和补偿解决。把两者混为一谈,往往在错误的层上花了力气。

八、长事务的超时该由谁持有

跨 SaaS 的多步操作天然是长事务,而长事务必然要面对"什么时候放弃"的问题。这里有一个具体的工程判断:超时预算应该由 Saga 协调者按剩余步数分配,而不是每一步各自设一个默认值。

原因很直接:一个五步的 Saga,如果每步都设 30 秒超时,总预算就是 150 秒,中间的等待时间没有被计入;反过来如果每步都按 30 秒算,用户在第一步等了 29 秒才失败,而后面的四步根本没机会跑。正确的做法是给整个 Saga 一个总预算,然后在协调者里维护一个剩余预算,每发起一步调用前先检查剩余量是否还够用这步的 p99 延迟,不够就直接进入补偿而不是发出去。

还有一个容易被忽略的点:人工确认的时间不应该算在技术超时里,但必须算在业务时效里。如果一个操作要等人审批 3 天,那么任何 30 分钟的技术超时都是无意义的,而"3 天后这笔预留还占着库存"就是个真实事故。正确做法是把预留资源的 TTL 显式设置成大于人工确认的预期时长,并且在这个时长上做统计和告警。

九、人工确认点该插在哪几处

确认点的设计是这类系统里最难取舍的部分。插得太少,风险敞口大;插得太多,人会形成"点确认"的肌肉记忆,反而比没有确认更危险。多数系统的失败模式是后者:确认弹窗点的人不看内容,审批流的形式意义大于实际意义。

所以正确的问题不是"要不要确认",而是在哪几个位置确认、每个位置给人看什么、以及确认这个动作本身能不能被信任。

三个判据决定确认的必要性

把第一节的不可逆性分级用起来,一次写操作是否需要确认,可以由三个判据共同决定。第一个是不可逆程度,也就是"撤销后外部世界还能不能装作没发生过"。第二个是影响面,一次操作涉及多少对象、涉及多少钱、涉及多少人。第三个是可检测性,如果这个操作做错了,能不能被快速发现并纠正——能被发现的操作,确认的价值就低一些,因为事后纠正的成本不高。

这三个判据的组合给出一个实用结论:不可逆且影响面大且不易察觉的操作必须确认;不可逆但影响面小的操作走确认加批量化;可逆的操作不要确认。给可逆操作加确认,是纯粹的成本——用户每次都要点,但点错了也能撤。

六个候选位置,以及各自该展示什么

把一次典型的跨系统任务拆开,会出现六个可以插入确认的位置。它们不是互斥的,选哪几个取决于业务,但每个位置要展示的信息是不同的。

  • 规划后的执行计划确认。在 Agent 给出计划、还没动任何数据时确认。展示内容应该是"将要发生什么"的清单:涉及哪些对象、哪些字段、可能的金额、哪些步骤不可逆。这一层拦的是"理解偏差"——Agent 想做的和用户想做的不是一回事。这是最有价值的一个确认点,因为它在所有副作用发生之前。
  • 不可逆动作前的确认。在调用第三方执行不可逆操作的那一刻之前。展示内容必须包含即将发给对方的完整参数原文,而不是摘要。摘要会丢掉细节,而细节恰恰是用户能判断对错的东西。比如发通知,摘要说"将发送通知邮件",用户没有判断依据;原文是"【订单】您的包裹已发出,运单号 SF1234,已于今天发出",用户一眼就知道该不该发。
  • 批量操作的分级确认。同一类操作影响 N 个对象时,不要弹 N 次。分级方式按影响面:涉及 1 到 3 个对象逐条确认;4 到 50 个对象展示分类汇总(比如"将向 32 个客户发送变更通知,涉及 3 个订单类型")加一个抽样明细;超过 50 个走单独的批量审批流,让有权限的角色在列表上逐条勾选。
  • 跨系统状态不一致的人工裁决。对账发现"你说成功实际失败"或者"你说失败实际成功"时,这类点不是确认,是裁决,必须展示两侧状态的对比和你的建议动作,且默认不预选任何一侧。
  • 补偿失败后的人工决策。Saga 补偿走到一半失败时,需要人决定"继续撤还是接受现状"。展示已撤的和撤不了的,撤不了的那部分要明确说清后果。
  • 权限升级的临时授权。当某个操作需要超出当前票据范围的权限时,这里是"临时提权"而不是"确认执行",展示的是"这次要额外获得什么权限、覆盖哪些对象、有效期多久"。

确认疲劳与降级策略

确认机制最大的失败是退化成人机之间的橡皮章。识别信号有三个:确认弹窗的停留时间越来越短;确认的通过率接近 100%;用户开始抱怨"为什么老是要点这个"。出现任一信号就要介入,而不是继续加提示语。

有效的手段是把确认和执行解耦:让用户一次确认"这一类操作的政策",而不是每一次操作。政策可以是"允许 Agent 调整金额小于 200 元的订单运费"、"允许对工单加内部备注但不允许改状态"。执行时由系统按政策自动放行,超出政策范围的才逐条确认。这样人看的对象从"几万次操作"降到"几条规则",而且规则是可以被审、被改、被追溯的对象。

另一个手段是确认内容的信息密度要随历史行为调整。如果某个 Agent 连续三个月提交同类操作且零返工,可以把确认降级为"批量页面 + 一键批准",但要保留可回溯的记录。反过来,如果最近有过一次需要撤销的操作,相关类型的确认要重新升级为逐条展示。降级和升级都应该有明确规则,而不是由某个人临时决定。

TOCTOU:确认和执行之间的时间窗

这是 Agent 场景特有的问题,而且很容易被忽略。用户看到确认弹窗时的数据,和系统真正执行时的数据,可能已经不一样了。间隔可能是几秒(用户思考的时间),也可能是几小时(用户批准了但请求在队列里排队)。

解决办法是把确认做成一张带版本号的凭证。确认时记录关键对象的版本号或内容哈希,执行前重新读取并比对,不一致就作废确认、重新走一遍。这个做法听起来繁琐,但它避免了一整类"用户明明批准的是 A,系统改成了 B"的事故,而这类事故在事后极难解释。

同时确认凭证必须有时效。十分钟前批准的操作,不应该在两小时后被执行,因为业务条件可能已经变化,而用户批准的是当时的语境。时效的设定可以参考业务本身的时效性:如果一小时的窗口内金额会变化,凭证就该更短。

确认不是授权

最后一个容易犯的错误是把"用户点了确认"直接当作"用户授权了这次操作",然后在审计里写成"经用户授权执行"。这两件事有本质区别:确认是对一次具体执行的知情同意,授权是对一类操作范围的许可。混用会在追责时出问题——用户可以说"我只是看到了那个弹窗,我没说我以后都让它随便改"。

所以系统里应该有两个独立的东西:确认凭证(针对本次执行,含参数原文、数据快照、版本号、时效)记录在执行记录里;授权策略(针对一类操作,含范围、额度、有效期、授予人)记录在策略库里。执行记录引用它所依据的策略版本,这样事后能回答两个不同的问题:"这次为什么被允许"(查策略)和"这个人是否知情"(查确认凭证)。

很多团队只有其中一个,于是要么说不清权限从哪来,要么说不清当事人是否知情。审计上这是两个独立问题,需要两份独立证据。

十、审计留痕:为了事后追责要记什么

前六节讲的都是"怎么把事做对",这一节讲"做错了怎么说得清楚"。这两件事的判据完全不同:一件事可以没有任何审计日志而完全正确,也可以每一行都记了日志但事后依然无法追责——因为记的东西不对。

Agent 场景让这个问题更尖锐。传统系统里,一次写操作的上下文是确定的:用户点的按钮、表单里填的值、代码里的分支。而 Agent 一次写操作的上下文包括模型版本、提示词、上下文里当时有哪些文档、工具的实现版本、模型推理出的中间决策。事后要复现"为什么它当时那么做",缺任何一环都断链。

追责要回答的四个问题

设计审计字段时,出发点不是"该记哪些字段",而是"事后要回答哪几个问题"。一份能用于追责的记录,至少要能回答:

  1. 谁触发的?是人主动发起、还是定时任务、还是上游系统回调、还是 Agent 自己续跑(上一轮超时后重试)。这个字段决定了责任归属的第一层。
  2. 它凭什么这么做的?当时的授权策略是什么、确认凭证是什么、上一步的输出是什么。缺这一条,就无法区分"系统设计成这样"和"有人临时改了配置"。
  3. 当时系统是什么状态?被修改对象在被改之前的完整内容、相关业务参数、当时的库存/额度/状态机位置。缺这一条,事后无法重建影响面,也无法判断这个操作在当时是否合理。
  4. 执行结果是什么?对方返回了什么、耗时多少、重试了几次、最终状态是成功/失败/未知。

这四问里,第三问最常被漏掉,而它恰恰是事后追责和事后修复都离不开的。很多系统只记了操作和结果,没记写前状态,结果出了问题只能靠回忆和猜测。写前快照是这类系统里性价比最高的一个字段,代价只是序列化一次对象。

事件模型与必备字段

审计留痕应该是事件流而不是日志行。日志行会被采样、被轮转、被日志系统按级别过滤掉,而审计事件不能有这些行为。每一类事件有自己的结构,但有几组字段是所有事件都必须带的。

字段组字段用途
身份event_id、run_id、step_id、actor(人或系统标识)、on_behalf_of(代表谁执行)把事件挂到具体的执行链路上,区分自主执行与代理执行
因果parent_event_id、caused_by(用户指令/上游事件/重试)重建决策链,定位"这个参数从哪来"
模型model_id、prompt_hash、tool_version、context_manifest复现当时的决策依据;prompt_hash 比存全文更经济
意图action、target、requested_fields、rationale说明想做什么,区别于"实际做了什么"
事实before_snapshot、after_snapshot、external_request、external_response影响面评估与修复的基础
授权policy_id、policy_version、approval_ticket_id、granted_scope回答"为什么被允许"和"是否有人知情"
结果state(succeeded/failed/unknown)、attempts、duration、error_class结果已知性;unknown 状态必须显式存在

注意 rationale 这个字段。它记的是模型声称的理由,不是真相,所以它不应该被当作证据,只能作为辅助线索。真正能作为证据的是 external_request——实际发出去的参数原文。这个区分很重要:事后看到模型说"我确认了金额是 100 元"和实际发出的是 1005 元,是两类完全不同的排查起点。

不可篡改:让记录本身有可信度

审计记录如果可以被修改或删除,它在追责场景下就没有价值。这里有三个层次的做法,按投入递增排列:

  • 最低层:只追加不修改。存储上用 append-only 的表或对象,只允许 INSERT,配合数据库的权限设置让应用账号没有 UPDATE 和 DELETE 权限。这一步能挡住应用层的误操作和大部分常规攻击。
  • 中间层:哈希链。每条记录包含前一条记录的哈希,形成链。任何一条被修改或删除,链就断了,检测方式是重算校验。这不需要引入区块链,数据库加一个字段就够了,但要在写入时同步计算。
  • 上层:定期把链的根哈希锚定到外部。如果连你自己数据库的管理员权限都不信任,可以把根哈希定期发到一个你控制之外的通道(另一个账号的存储、或者一个只追加的外部服务)。这一步的价值不在技术,在心理阈值——它把"改数据"这件事从"一个技术动作"变成"一个会留下外部痕迹的动作",这个改变对内部行为的影响往往比技术保证更大。

实现:只追加事件链

# 只追加事件链:每条事件带前一条的哈希,任何篡改都会在重算时暴露。
# 注意这里没有 UPDATE 路径 —— 表结构上就不提供修改入口。

import time
import json
import hmac
import hashlib
from dataclasses import dataclass, asdict, field


# 哈希链的锚定目标与普通记录是分开的存储,签名密钥也分开
ANCHOR_INTERVAL = 300


@dataclass
class AuditEvent:
    event_id: str
    run_id: str
    step_id: str
    actor: str                 # 人 / 定时任务 / 上游系统
    on_behalf_of: str          # 代表哪个租户或哪个用户
    action: str                # 想做的事
    state: str                 # succeeded / failed / unknown
    intent: dict = field(default_factory=dict)    # 意图与理由(辅助线索)
    before_snapshot: dict = field(default_factory=dict)
    after_snapshot: dict = field(default_factory=dict)
    external_request: dict = field(default_factory=dict)
    external_response: dict = field(default_factory=dict)
    authorization: dict = field(default_factory=dict)  # policy + approval ticket
    model_context: dict = field(default_factory=dict)  # model/prompt hash/tool version
    attempts: int = 1
    duration_ms: int = 0
    prev_hash: str = ""
    hash: str = ""
    ts: float = 0.0


class AppendOnlyAudit:
    def __init__(self, store, chain_key: bytes, sign_key: bytes):
        self.store = store
        self.chain_key = chain_key
        self.sign_key = sign_key
        self.last_hash = store.tail_hash() or ("0" * 64)

    def append(self, event: AuditEvent):
        # 关键顺序:先算哈希再落盘,落盘后推进 last_hash。
        # 如果进程在两步之间挂掉,这条事件丢失但链未断;
        # 如果反过来先推进后落盘,链会指向一条不存在的记录。
        event.prev_hash = self.last_hash
        event.ts = time.time()
        payload = json.dumps(asdict(event), sort_keys=True,
                             ensure_ascii=False, default=str)
        event.hash = hmac.new(self.chain_key, payload.encode(),
                              hashlib.sha256).hexdigest()
        self.store.insert(event)          # 只 INSERT
        self.last_hash = event.hash
        return event

    def verify_chain(self, limit=100000):
        """重算校验。返回断裂位置;返回 None 表示链完整。"""
        prev = "0" * 64
        for e in self.store.iter_from_head(limit):
            if e.prev_hash != prev:
                return {"broken_at": e.event_id, "reason": "prev_hash 不匹配"}
            body = dict(asdict(e))
            claimed = body.pop("hash")
            body["hash"] = ""
            payload = json.dumps(body, sort_keys=True,
                                 ensure_ascii=False, default=str)
            # 落盘时 hash 字段为空参与计算,复算时保持一致
            recomputed = hmac.new(self.chain_key, payload.encode(),
                                  hashlib.sha256).hexdigest()
            if recomputed != claimed:
                return {"broken_at": e.event_id, "reason": "内容与哈希不符"}
            prev = e.hash
        return None

    def anchor(self, anchor_store):
        """把链的当前根哈希 + 签名推到外部存储,外部拿不到内容只能验证。"""
        body = json.dumps({
            "head": self.last_hash,
            "count": self.store.count(),
            "ts": time.time(),
        }, sort_keys=True).encode()
        sig = hmac.new(self.sign_key, body, hashlib.sha256).hexdigest()
        anchor_store.put({"head": self.last_hash, "count": self.store.count(),
                          "sig": sig, "ts": time.time()})
        return sig

    def replay_for_incident(self, run_id):
        """事故复盘的主查询:按 run 拉全链路,缺失的因果关系要显式标出。"""
        events = [asdict(e) for e in self.store.by_run(run_id)]
        by_id = {e["event_id"]: e for e in events}
        chain = []
        for e in sorted(events, key=lambda x: x["ts"]):
            e["causal_parent_present"] = (
                not e.get("parent_event_id")
                or e["parent_event_id"] in by_id)
            chain.append(e)
        # unknown 状态必须单独挑出来,这些是需要人工跟进的对象
        unknown = [e for e in chain if e["state"] == "unknown"]
        return {"run_id": run_id, "events": chain, "needs_followup": unknown}

保留期、脱敏与访问控制

审计数据的保留策略要按内容分级,而不是统一设置。不可逆操作的事件(付款、对外发送、权限变更)应该保留足够长以覆盖相关的争议周期和监管要求,通常是数年级别,而且到期后删除应该是"归档"而不是"抹除"——归档的部分仍可查询到摘要,原始报文按合规要求处理。

这里有个容易被忽略的张力:审计要记录"发出去的完整参数原文",而参数里可能含个人信息;保留期又比业务数据长得多。解法是对敏感字段做记录时的结构化脱敏——记字段名、记哈希、记部分前缀和后缀,而不是全量明文。全量明文放在有独立权限和独立加密的窄存储里,访问需要二次审批,并且每次访问本身也要进审计(否则"谁看过用户的支付信息"这个问题永远答不了)。

最后一件事:审计数据本身要能被检索到有用结论。字段设计时就想清楚"下次出事故我会查什么",通常至少需要按租户、按时间范围、按操作对象 ID、按操作类型、按最终状态这五个维度建索引。尤其是"按最终状态筛 unknown"——这是悬挂操作巡检的主查询路径,索引没建的话,巡检任务会退化成全表扫描然后没人跑。

十一、端到端走一遍:一次跨系统合并发货任务

前面十节是拆开讲的机制,这一节把它们串起来跑一遍,因为很多设计上的取舍只有在完整流程里才显出代价。场景选一个典型的、几乎每个做集成的团队都会遇到的客服工单:客户在对话里说"我上周下的四单能不能合并成一单发,别给我发四条物流通知"。这个请求看起来只是一句话,落到系统里要动四个系统六类状态,而且其中两类是不可逆的对外动作。

从一句话到一组受约束的动作

Agent 读完这句话,先要把它翻译成业务语义,而不是直接翻译成接口调用。这里有一块必须由系统提供、模型不能自己编的知识:哪些订单可以被合并、合并的约束是什么、合并后物流费怎么算、发票能不能跨订单合并。这些规则存在于商家的业务约定里,写在几份互相矛盾的运营文档里,而且三个月前刚改过一版。

正确的做法是让模型负责"从自然语言里识别意图和参数",让规则引擎负责"判断这些参数是否构成一个合法操作"。前者是模型擅长的,后者是模型不可靠的。如果把合法性判断也交给模型,得到的结论大概率是:这四单可以合并,因为从文本上看它们确实像同一批——而实际上其中一单用了优惠券、一单已经部分发货、还有一单是预售。模型不会知道这些,因为这些信息不在用户的这句话里,也不在它的上下文里。

所以流程的第一步不是规划,是取数。系统要一次性把四张订单的当前状态、地址、支付方式、可用优惠券、已占用的库存、发票状态全部读出来,作为这次任务的输入快照。这一步之后的所有判断都基于这份快照,而不是基于模型随时可能刷新的查询结果。这一点决定了后面所有确认点里展示的内容都是自洽的——用户看到的、执行时校验的、审计里记录的,用的是同一份数据。

第一个确认点:执行计划,而不是执行结果

规则引擎跑完之后,产生的是一份结构化的动作清单,而不是一个可执行脚本。这时才轮到模型发挥作用:把这份清单翻译成人能看懂的话,指出哪几步需要向用户说明额外风险,比如"其中一张订单使用了优惠券,合并后优惠券将不予保留,抵扣金额不予返还"。模型在这一步的作用是表达,不是决策。

用户看到的确认页面应该包含这样几块:将要合并的订单清单及各自关键信息、将要发生的每一类状态变化、哪些变化不可逆、如果中途失败会怎样、有没有需要用户做的选择。页面上不应该出现的是模型对这次操作的"信心程度"描述——那是一个用户无法据此判断的数字。

这个确认点的价值在于它拦的是"理解偏差",而理解偏差是所有事故里最难事后修复的一类。如果订单已经合并、通知已经发出,你再告诉用户"其实你只是想问能不能合并",已经没有任何意义了。而在零副作用的时候纠正理解,成本几乎为零。所以只要任务涉及不可逆动作,第一个确认点就应该放在任何副作用之前,哪怕它只覆盖了一部分场景。

走到一半:开票接口超时

计划确认后开始执行。前两步顺利:合并请求提交成功,物流单号生成成功,物流系统那边的运单状态改成待揽收。第三步调电子发票服务开票,对方在处理过程中连接被重置,客户端拿不到任何响应。

这是全文反复强调的那个最难处理的状态:不知道对方到底开没开。注意这里的重试陷阱——如果按常规写法捕获到超时异常就重试,会出现两种坏结果里的任意一种:对方其实已经开好票了,重试导致同一笔业务开了两张票;或者对方没开票,但第一次的请求还在对方队列里排队,重试导致开第二张。区别在于前者是对账之前根本看不出来,后者在几秒之后会自己暴露成"重复开票",还算是运气好的那种。

正确的处理包含三个动作,缺一不可。第一是把这步标记为悬挂而不是标记为失败,幂等台账里的状态字段必须能表达这个第三态。第二是用只读接口探测:按业务单号去发票服务查有没有已经生成的发票,这是幂等的,因为查询不产生副作用。第三是把决定权交给人,如果探测也给不出确定答案,就生成一个人工工单,把两侧的证据都附上。

这里有个务实的判断:查询接口的存在率比想象中低。很多第三方服务只提供写接口不提供按业务键查询,或者查询有分钟级延迟。所以探测失败后转人工不是设计缺陷,是正常路径。有相当比例的自动化系统在生产里的实际形态是"自动处理确定的部分,剩下的排队等人",而不是"全自动"。把这个预期在设计阶段就和业务方讲清楚,比上线后再解释要好得多。

补偿到一半卡住

假设四张订单里有一张用了不可退的优惠券,规则引擎给出的结论是这单不能合并、需要单独发货。任务在执行到一半时才发现这个约束,属于部分成功。系统需要决定是撤销前两步还是继续向前。

按补偿分级来判断:物流单号已经生成但尚未揽收,撤销它属于弱补偿——因为运单号一旦生成就已经进入了物流系统的队列,虽然还没人揽收,但对方业务系统里已经有这条记录了。合并订单属于强补偿,因为它本质是数据结构变换,拆回去不留可见痕迹。给客户发通知还没执行,所以不在补偿计划里。

补偿计划因此是先撤合并、再撤运单。撤合并是确定性的、幂等的,直接调用即可。撤运单要调物流的取消接口,而这个接口有它的业务规则:已生成运单且未揽收通常可以取消,但如果物流公司已经分配了网点并推送了轨迹信息,取消就会失败。调用失败时,系统不能简单地认为整个补偿失败然后重试整个流程,而要按补偿的粒度单独标记这步为"补偿失败",并进入人工队列。

最终交付给用户的界面必须如实反映这个状态:合并已撤销,运单取消失败(原因是物流已分配网点),建议的处理方式是联系人工客服取消原运单,或者等待用户签收后申请退款。把它显示成"操作失败,请重试"是信息损失,用户会重试,然后得到一堆重复的运单。

事后能拿出的东西

这类任务跑完之后,审计系统里应该有这样一串记录:用户原始请求的原文与解析出的意图参数、四张订单在任务开始时的完整快照、规则引擎的判定结论与命中的规则版本、计划确认凭证(含用户看到的页面内容哈希与时间戳)、每一步的外部请求原文与响应、悬挂那一步的三态标记与探测结果、补偿计划的每一步及其结果、最终交付给用户的说明文本。

这串记录能回答的实际问题包括:为什么这单被判定为不能合并;用户知不知道优惠券会被收回;这单有没有被重复处理;以及如果业务方事后声称当时页面没显示那个提示,可以拿页面快照和当时渲染的模板版本做对照。

做不到的事情也要诚实标出来。如果当时没有记录规则版本,那就无法解释为什么这么判定;如果没有记写前快照,就无法评估那四张订单的后续状态被影响了多少。审计的价值取决于最弱的那一环,而不是最全的那一环。

十二、接入层那些不性感、但决定成败的工程细节

前面十二节讨论的都是控制流:什么时候停、什么时候撤、什么时候问人。但真实项目里让 Agent 团队栽跟头的,往往是控制流全都做对了,数据却错得一塌糊涂。这类问题的共同特征是:不会报错,只会产生错误的结果,而且错误结果看起来完全合理,比明显的崩溃更难发现。

同一个字段,两套语义

跨系统集成最常见的错误来源是字段同名不同义。一个叫"金额"的字段,电商平台返回的是以分为单位的整数,支付网关返回的是以元为单位的浮点数,财务系统返回的是含税价还是不含税价则取决于对方哪个接口。Agent 从用户的一句话里提取出一个金额,经过模型、工具、网关、外部系统四层传递,每一层都以为自己没做转换是对的。

这里的关键判断是:单位换算必须发生在边界上,而不是在业务代码里。也就是说,从外部系统读进来的每一个数值,在进入内部数据结构之前就要转成内部统一单位,而且这个转换应该在工具层强制发生,不给业务代码"我拿到的就是分"的选择权。一旦某个接口忘记转换,错误会以乘以一百的形式出现在最终结果里,而乘一百和除一百的错误在报表上往往都"看起来像那么回事"。

时区问题比单位问题更隐蔽,因为它涉及语义判断而不是数值换算。第三方系统给的时间戳可能是不带时区的本地时间,可能是以秒为单位的 Unix 时间,也可能是毫秒。毫秒和秒混用会让所有时间差计算出一个一千倍的偏差,这类偏差在日志里表现为"耗时特别长"而不是"数据错误",排查成本极高。更麻烦的是夏令时:某些时区在一年里有两次当地时间重复,朴素的时间比较会认为前后顺序颠倒。

务实的做法是在内部统一使用带时区的时间戳,并且在工具层对外部时间做一次显式解析和断言:解析失败或者结果超出合理范围(比如订单创建时间在未来七十二小时内)时直接拒绝,把问题暴露在接入层,而不是让它流到业务逻辑里再被发现。

金额的精度与舍入方向

金额比数量更麻烦,因为它有精度要求和舍入规则,而这个规则如果不显式声明,两次计算的结果会差一分钱,一分钱在对账时就是一整条不平的记录。涉及多系统分摊的场景更复杂:一笔运费要按三个订单的金额比例分摊,比例相加不严格等于一,最后必然要有一笔承担舍入误差。

这里应该明确一条规则:分摊时前几项一律向下取整,剩余部分全部给最后一项。这个规则的价值不在于数学上更优,而在于它是确定性的、可复算的、和顺序无关的。任何"四舍五入到分然后看看总额对不对得上"的实现,都是在把对账问题变成随机问题。

另一个必须提前决定的问题是货币。跨币种场景下,"100 元"在写入外部系统时按哪个汇率换算,汇率取自哪个时间点,中间产生的汇兑损益归谁承担。这些问题不解决,Agent 在执行"把金额转换成美元"的指令时就会随手用一个当前的汇率,而正确做法是使用订单创建时锁定的汇率。汇率这类外部数据源,应该和业务规则一样进入"模型不许自己编"的清单。

字段缺失、null 和"没有值"的三重语义

很多接口里"字段不存在"和"字段值为空"和"字段为零"是三件不同的事,但对模型来说它们可能长得一样。如果一个工具把"收货地址不存在"表示成空字符串,模型可能理解成"用户要求清空地址",然后去调用一个设置地址的接口传入空值,结果客户的地址被清掉了。

防御方式是在工具契约里把每个字段的三种状态显式建模,并且规定不同状态的默认行为。最实用的规则是:外部返回的缺失一律解释为"不知道",不允许被解释为"请清空"。要清空必须由用户的明确指令触发,走一条单独的路径。这个规则看起来是小事,但它拦住的是一类非常容易发生且后果不可逆的数据破坏。

枚举值和状态机的漂移

第三方 SaaS 的枚举值会变,而且他们不会提前通知你。订单状态从五档变成六档、发货状态的字符串大小写改了、某个状态被废弃但仍会在历史数据里出现。这些变化不会让你的代码报错——它只会让某个分支永远走不到,或者让某个判断落到错误的分支上。

应对方法有三个。一是把外部枚举在接入层映射成内部枚举,并且对无法识别的值不要静默丢弃,而是归入一个"未知"档位并打点告警,这样当对方新增状态时你能立刻知道。二是定期主动探测,用一个沙箱环境或者一个测试租户定期跑一遍,把出现过的所有枚举值和已知的做差集。三是让状态机的校验在写入前做,因为"从待付款直接跳到已签收"这种非法跃迁在对方接口可能返回成功,但你自己的业务一定不允许。

租户隔离要在服务端算,不在参数里传

多租户系统里最容易出的事故是跨租户读写。技术上它通常源于一个很自然的错误:把租户标识当成一个普通参数传给查询方法,调用方自己填。Agent 场景下这个错误特别容易发生,因为模型看到工具描述里有这个字段,它会自己填上,而它填的值来自上下文——可能来自用户的一句话,可能来自一次文档检索,甚至可能是一次幻觉。

正确做法是租户标识从凭证上下文里解析出来,在数据访问层统一注入,模型和业务代码都接触不到它。同时在授权层做一次显式断言:这次操作涉及的每一个资源标识,都必须属于当前租户,否则直接拒绝。断言的成本极低,但它拦下的是最严重的一类事故,而且这类事故往往在事后很久才被发现——因为客户之间不会互相举报。

限流不是可靠性问题,是配额问题

第三方 SaaS 的速率限制在 Agent 场景里表现得更糟,因为 Agent 会自然地产生突发:一次任务规划出十几个工具调用,很可能在几秒内全部打向同一个接口。这在传统集成里可以用令牌桶削峰,在 Agent 场景里削峰会导致任务长时间挂起,用户看到的是一个"转圈很久"的界面。

这里要做的是让模型参与调度决策。一个可行的机制是给每个工具的描述里带上"该接口当前有拥塞",让模型自己选择替代路径或者分批执行。这比在网关层硬排队更灵活,因为模型有语义理解能力,能判断哪些调用可以合并、哪些可以推后。代价是这个机制依赖模型真的读了这段描述,所以更可靠的做法是把它做成工具层的硬约束:调用前检查,拥塞则直接返回一个结构化的"稍后重试"结果,让模型拿到一个明确的信号而不是一个超时。

所有关于"让模型自己权衡"的机制,都要配一个兜底的硬约束。前者提供灵活性,后者提供确定性,只有后者能保证出问题时系统不会失守。

Webhook 与异步回调带来的确认问题

一部分 SaaS 的写操作是异步的:调用立刻返回受理成功,真正的结果通过回调通知。这意味着上一节说的"结果已知性"问题被推到了更远的地方——你不仅要处理同步超时,还要处理一个永远不来的回调、一个重复的回调、一个乱序的回调。

对于这类接口,正确的建模方式是把操作建模成一个有生命周期的对象:受理、进行中、成功、失败、已超时。同步返回只能把状态推进到"进行中",之后唯一能把状态推进到终态的就是回调,或者一个兜底的查询轮询。回调处理必须幂等,因为对方不保证只发一次;必须校验签名,因为回调是外部输入;必须容忍乱序,因为先到的失败回调可能覆盖后到的成功回调,所以状态推进要按业务语义判断而不是无脑覆盖。

如果一个接口有异步特性,它就不该由 Agent 自己轮询等待,而应该由系统维护一个任务对象,Agent 的职责在提交那一刻就结束了,界面上的状态由系统的回调和轮询更新。这既避免了 Agent 在一个循环里反复查询耗尽配额,也避免了在等待期间因为上下文丢失而丢失状态。

十三、落地顺序:先做哪一件

前面讲了十几个机制,实践中它们不需要同时建。这里有一个顺序上的判断可以省掉大量返工:从你最不可逆的那个动作开始,而不是从最常调用的那个接口开始。理由有两个。一是不可逆动作的容错窗口最短,一旦出事就没有第二次机会,你被迫要在第一次就把它做对;二是不可逆动作通常调用频率低,机制建在它上面的维护成本可控,而高频接口上堆机制很容易因为摩擦太大而被团队绕过。

具体来说,第一步是给出一个明确的风险清单:把这次要开放给 Agent 的所有写操作列出来,逐个标注不可逆程度和影响面,标注依据要具体到业务含义而不是接口路径。这一步不需要写代码,但它决定了后面所有工作的顺序,也是唯一一份能在半年后回答"当初为什么这么定"的文档。

第二步是给清单里最不可逆的那两三个动作建能力票据。这套东西是后面所有机制的地基,因为确认点、审计、幂等台账都要往票据上挂标识。先建票据的好处是强制你想清楚"这次操作允许碰哪些对象、哪些字段",而这个问题不解决,后面每一项机制都会各自发明一套参数校验。

第三步是给这几类动作建写前快照。快照的收益是立竿见影的:它不阻止错误发生,但让错误的成本从"无法处理"降到"可以还原",而这个降低足以让业务方愿意在当前阶段就开放写权限。业务方最常见的顾虑是"出了事怎么办",快照加上人工核对就是这个问题的一个诚实答案。

第四步是接审计。审计必须和前几步同时上,晚一步就意味着有一批没有审计的操作已经产生,而它们的写前快照和确认凭证都不存在,这批数据从此是不可追责的。审计本身不需要完备,但事件骨架要一次定对,因为事后补记的字段和当时记的字段往往对不上,历史数据的价值会在补录环节大量损失。

第五步才是幂等台账和补偿机制。这两个可以晚一点,因为它们主要解决"重复"和"部分成功",而在你把流量控制在小范围时,这两个问题的发生频率还不足以支撑建设成本。等确认点和审计都就位、你开始放量并且真的观测到了重复执行,再建台账,此时你还知道该针对什么建。

第六步是两阶段相关的机制。这一类应该放在最后,因为它成本最高、复杂度最大,而且只有在其他手段都失效之后才需要它。很多团队跳过了前面五步直接做两阶段,最后发现真正的问题不是"提交到一半",而是"根本没检查该不该提交"。

十四、怎么验证一个接了真实系统的 Agent 是对的

这类系统的测试困难是出了名的:输出不确定,副作用不可回放,环境不隔离。但有几种手段的性价比明显高于其他几种,值得优先建设。

录制的生产流量回放

把线上真实发生的对话和对应的工具调用录下来,脱敏之后作为回归用例。这解决的是两个问题:一是防止改动引入回归,二是让"这个任务当时是怎么处理的"变成可对照的基准。回放时把外部系统替换成一个行为等价的假服务,关键是假服务要能产生真实的失败模式——超时、限流、字段缺失、重复提交,而不只是返回成功。如果假服务只返回成功,那么这个测试只能覆盖顺利路径,而顺利路径恰好是问题最少的那条。

影子流量与数据对平

把新逻辑接在真实流量的只读路径上,让它算出"如果执行会发生什么",但不真的执行,然后和真实结果做对平。这个方法的价值在于它能覆盖真实数据的分布,而真实数据的分布是单元测试永远构造不出来的:那些空的、极端的、格式古怪的、语义矛盾的历史数据,只有生产库里才有。

对平的对象不是"结果一样"而是"关键字段一致"。因为系统里有大量合理的非确定性——时间戳、生成的编号、模型措辞——它们本来就不该一致。真正要对平的是金额、数量、状态、目标对象标识这类业务字段。这个区分不划清楚,对平报告会全是噪声,然后就没有人看了。

对账任务作为测试用例来源

生产环境里跑的对账任务,每发现一次不一致,就应该沉淀成一条固定的回归用例。这是本文认为性价比最高的一种做法,因为它把运维成本转化成了质量资产,而且用例来源是真实的、经过验证的、没有人为构想的成分。很多团队的测试集全是人工编造的边界情况,而真实世界爆出来的那些刁钻场景一个都没覆盖到。

契约测试要双向做

你对第三方接口的理解一定会有错,而这类错误的表现是安静的错误值而不是异常,所以必须主动验证。做法是定期在一个沙箱租户里跑一遍所有读接口,把返回的字段集合、枚举取值、类型分布与你内置的模型做差集,新增的字段和值要人工确认含义。同时反向验证:按你理解的契约构造请求发过去,看对方是否接受。这个双向检查能提前发现两种典型错误——对方悄悄加了一个你没有处理的字段,或者你对某个必填参数的理解是错的。

故障注入要打在对的地方

前面讲了悬挂、超时、限流、重复提交这些状态,故障注入的目的就是验证系统的分类逻辑真的能处理它们。但注入点要选得准:最有效的注入点不是随机超时,而是响应到达之后、状态落库之前这个窗口,因为这个窗口正好对应"对方已执行、我方认为失败"的最坏情况。第二个有效的注入点是在确认凭证生成之后、执行之前,验证版本校验是否真的拦得住变化。

这两个注入点的共同特点是:它们检验的是系统里那些"顺序敏感"的不变量,而顺序敏感的逻辑正是 Agent 集成里最容易写错、又最难通过读代码发现的部分。随机超时反而效果一般,因为大多数重试逻辑对随机超时的处理本来就是对的——真正的问题出现在那些"看起来像失败但其实不是失败"的场合。

十五、八个会把自己绕进去的做法

把实践中最常被批评的做法列出来,它们有一个共同点:单看每一条都很合理,组合起来就是一个无法追责的系统。

第一,把确认做成一句口头询问而不是一张凭证。这样事后无法证明当事人知情,也无法在数据变化后拒绝执行。第二,补偿失败之后自动重试整个流程,而不是单独重试失败的那一步。第三,把不确定结果统一归类为失败,这是所有重复问题的源头。第四,权限判断写在工具描述里指望模型遵守。第五,用重试代替幂等。第六,把幂等键塞进请求体的一个自定字段里,然后假设对方会去检查它。第七,只记操作结果不记写前状态。第八,把长事务的超时设成一个全局常数,于是要么等太久要么太短。

这八条里,有五条的共同根因是把"不知道"当成了"失败"或者"成功"。系统里最需要被显式建模的状态是未知,而它最容易被默认值吞掉。剩下的三条根因是顺序问题:先执行后记录、先信任后校验、先重试后判断。凡是涉及外部副作用的代码,值得逐行问一句"这个操作的顺序能不能换,换了会怎样",很多事故的答案就在这三行里。

十六、身份与凭据:谁在执行这件事

接入真实系统之后,一个绕不过去的问题是:这次写操作到底是谁发起的。这个问题看起来是审计问题,实际上是权限设计问题,因为它直接决定了拒绝服务、额度扣减、追责对象和回调归属这四件事。集成架构里一个含糊的默认值,会在这里同时引发四个不同方向的问题。

代表用户执行,还是代表系统执行

有两种语义完全不同的模式。采用代表用户模式时,Agent 的每一次调用都携带最终用户的身份,对外表现是"这个用户自己改的"。采用代表系统模式时,Agent 用一个系统账号调用,对外表现是"系统自动改的",用户的身份只记录在内部日志里。这两种模式对业务方的意义差别极大:前者要处理"这个用户有没有权限"的问题,后者不需要,但所有操作都会挂在一个系统账号名下,出了事必须靠内部审计才能定位到人。

选错的后果是连锁的。客服场景通常适合代表用户,因为业务方已有的权限模型是按用户建的,直接复用最省事,而且审计天然对得上。批量对账、报表生成这类任务通常只能代表系统,因为发起人是一个定时任务而不是某个人。这里最容易出错的地方是混合场景:Agent 帮用户执行了一次操作,但这操作触发了系统的一个后台任务,那个后台任务再去调另一个服务时身份就丢了,最终对方记录里看不到任何用户信息。这类身份断裂在事后追责时是致命的,因为对方系统里那条记录没有任何指向具体人的线索。

务实的做法是让身份显式地跟着任务走,而不是依赖调用栈的隐式传递:任务的上下文里记录发起人身份,所有对外调用都从任务上下文里取,不从全局会话里取。这样即使流程里经过了队列、经过了另一个服务、经过了模型的多轮工具调用,身份也不会丢。代价是每个服务都要显式地实现"从上下文取身份"而不是"从线程取身份",这需要一点规范和检查,但比重构对方的系统便宜得多。

令牌的生命周期要独立于任务

当一个任务的执行时间可能超过令牌的过期时间时(人工确认排了几小时的队列、限流下排队等了很久的批量任务),令牌过期会让流程中途失败,而失败位置往往在某个不可逆动作之前或之后,处置起来比单纯失败更麻烦。

比较稳的做法是让令牌刷新成为网关的一个自动行为,由网关统一处理,同时在审计里记录"这次调用用的是哪个令牌的哪个版本"以及"是否发生过刷新"。这个记录在追责时有实际意义:如果事故涉及权限变更,你需要知道当时用户的权限状态,而现在很多系统只能回答"用户的权限在某个时间点被改过",回答不了"这次调用发生时他的权限是什么"。

临时提权要留痕并自动过期

前面提到过"临时提权"这个确认点。实现上它必须是一条有时效的授权记录,而不是在令牌里偷偷加一个更高权限然后指望它自然消失。区别在于前者的每一次使用都能被记录和复盘,后者只有生效和失效两个时间点,中间的每一次调用都无从追溯。

临时提权的时效设置要短。这里的原则是:提权的有效期不超过完成这次任务所需的时间乘以一个余量,而不是一个看起来合理的整数天。因为临时权限真正的风险不是它长期存在,而是它在长期存在期间被用于了原本没打算执行的另一个操作——也就是提权范围和提权后的实际用途之间出现了偏差。把提权绑定到具体的资源集合和具体的动作上,可以大幅缩小这个偏差的空间。

十七、写操作的成本闸门

读操作的资源和成本问题在别的话题里已经讨论得比较充分了,写操作有一类独特的成本经常被忽略:它是可被放大的。一个用户的自然语言请求,如果被模型理解成"把这批订单都改一下",而"这批"在一万条上下文里包含了两千个符合条件的对象,那么一次对话的成本和副作用规模都是不可控的。

控制这个问题有三个层次。第一层是意图澄清:当匹配到的对象数量超过某个阈值时,正确的行为是回去问用户而不是继续执行,因为用户大概率没有意识到自己这句话的字面含义会覆盖这么大范围。这个阈值要设得足够小,小到用户不会觉得烦。第二层是执行配额:单个任务内,同一类写操作的成功次数硬性封顶,超出就停下来把剩余部分交给人。第三层是速率限制:单位时间内允许执行的写操作总次数。

这三层里最容易被漏掉的是第一层。第二层和第三层是技术手段,它们能防止系统被打爆,但防止不了"系统正确地做了一件用户没想要的事"。这个区别在事后追责时很关键:如果用户投诉"我没让它改这些",技术上你的配额和限流都是正常工作的,责任在语义层,而语义层的防线只能是对话里的澄清提问。

另一个关于成本的判断是关于重试的成本权重。一次写操作失败后的重试,在很多系统里被当成一次廉价的重试,但从业务角度看它可能非常贵——如果第一次其实成功了,重试产生的重复记录需要人工清理,清理成本远大于重试本身的成本。所以对写操作应该用更保守的退避策略,宁可失败快一点转人工,也不要在一个不确定的结果上反复试探。

十八、跨任务的去重

前面讨论的幂等都是单次任务内的,键来自这一步操作的业务标识。但还有一类更隐蔽的重复:用户在两次不同的对话里表达了同一个意图,产生了两次独立的任务。

典型场景是用户说了"帮我把这个订单改成明天发货",执行到一半因为一个外部问题失败了,界面提示"操作未完成,是否重试",用户点了重试——但系统是新建了一个任务,而不是恢复原任务。两次任务各自有独立的幂等键,第二次任务里的第一次调用不会被自己的台账拦住,于是产生重复。

解决这个问题的关键是把"用户意图"作为去重维度,而不只是"这一步操作"。具体做法是给每个任务生成一个意图指纹:包含任务类型、目标对象集合、以及关键参数的规范化表示。指纹相同的任务在短时间内不允许并行存在,要么复用原任务继续,要么明确宣告前一个任务已终止并完成它的补偿。

这个机制的成本是要处理指纹碰撞——两个真正不同的任务被误判为重复。判断标准是宁可误判为重复也不误判为新任务,因为前者只影响效率,后者影响数据正确性。指纹里应该包含足够多的区分维度,把这个取舍做实。相关的一个细节是"重试"按钮的实现方式:如果它真的新建任务,那界面上就不该叫重试,而该叫"重新开始",这个措辞差异能显著减少用户对系统行为的误解。

十九、边界、代价,以及干脆不给写权限这个选项

把前面十八节的内容合起来是一套相当重的工程。它的实际成本包括:能力票据这一层要维护签发和校验;幂等台账要建表、要处理三态、要有人写对账任务;Saga 要能恢复、要能补偿;确认点要设计页面、要处理版本校验;审计要只追加、要算哈希、要定期锚定;接入层要做单位时区金额的规范化。这些加起来不是一个小型项目,而且它们全部是长期维护负担,不是写完就结束的。

所以有一个必须诚实地回答的问题:这套东西值不值。判断的输入不是"技术上能不能做",而是"这个业务场景里,Agent 执行一次错误写操作的最坏损失"和"这个业务场景的操作频次"。两者都低的时候,最优解往往不是把上面这套做全,而是根本不给写权限。

写权限的三种替代方案

方案一是只读加提案。Agent 读所有需要的数据,产出一份结构化的待执行提案,用户在独立的界面上核对后由人点执行。这个方案的收益是所有写操作的不可逆性都不再是问题,因为执行者是人和一个确定性的系统,模型只负责"读和算"。代价是自动化率低,长流程的体验不如全自动。它的适用面比人们想象得宽——很多看起来需要全自动的场景,实际业务方更想要一个"帮我把这一步做完"而不是"把这件事全自动做了"。

方案二是按操作类型分权。只开放可撤销的写操作,把真正不可逆的那部分保留人工。具体做法是让 Agent 有权创建草稿、修改内部备注、调整非关键的内部状态、有权限地创建工单,但无权付款、无权发货、无权对外发送。这样系统仍然自动处理了大量事务,剩下少量高风险动作走确认。多数业务的真实情况是:高风险动作在频次上只占百分之几,而低风险动作占绝大多数,所以这个方案能覆盖大部分价值。

方案三是延迟执行加批量确认。Agent 把所有要做的操作攒起来,凑够一个批次或者等一个时间窗口,然后把整批操作以清单形式呈现给用户一次确认。它的价值是把确认的粒度从"每次操作"变成"每个批次",大幅降低确认疲劳,同时保留人对不可逆动作的控制。适用条件是这些操作之间相互独立,如果存在依赖关系,批量化会让部分失败的处理复杂很多。

这三种方案不是互斥的,常见的最优组合是:用第一种方案覆盖所有不可逆操作,用第三种方案承载低风险操作的批量执行,把中间地带留给全自动。在这个组合里,全自动的部分只剩下那些即使出错也能被自动检测和自动修复的操作。

什么时候这套机制不划算

有几类场景可以明确地说"不值得"。第一类是单租户、内部工具、操作低频且可撤销的场景,比如一个团队内部用的运维助手,它能改的配置项改错了五分钟就恢复,那完整的机制建设成本远超收益,直接做写前快照加审计就够了。第二类是写操作的价值远低于读操作的场景,如果 Agent 的核心价值在于帮用户找到和理解信息,那么为写操作付出的全部工程投入都可以省下来,专注把读做好。

第三类更微妙:业务方无法接受确认流程带来的延迟的场景。如果一个场景里用户期望的是即时完成,而引入确认后要等几秒到几十秒,产品就会被否决。这时正确的做法不是悄悄去掉确认,而是回到前面那三种替代方案里找一条既保留控制又保留速度的路,通常答案在方案三的批量化上。

这套机制的真实代价

工程上的代价前面已经说了,更容易被低估的是流程和认知上的代价。第一,团队的日常会被"为什么这个要确认那个不用"这类问题持续消耗,如果没有一份明确的规则文档,这些问题会反复讨论。第二,故障处理变得更有仪式感,值班的人需要学会读悬挂台账和对账报告,这需要培训。第三,所有涉及写操作的代码改动都必须考虑这三个问题——这个操作可逆吗、结果未知时怎么办、事后能不能解释,而这种思维方式的建立比写某个具体机制更慢。

这些代价换来的是一类能力:当系统出错时,你能准确说出它做了什么、依据是什么、谁批准的、当时数据是什么样。这个能力的价值在系统规模小的时候看不出来,在系统开始承载真实业务量并且真的出过事之后就很难用别的方式替代。所以正确的判断不是"要不要上这套机制",而是"这个系统的预期寿命和业务重要性,值不值得为可追责性付费"。

二十、给一次写权限的最小充分条件

如果要把这篇文章压缩成一份可以逐条检查的清单,下面这些是本文认为缺一不可的。它们的排列顺序也是建议的建设顺序。

关于权限边界:

  • 写操作和读操作使用不同的凭证,模型和业务代码都拿不到写凭证。
  • 租户标识由凭证解析得出并统一注入,任何地方都不接受模型提供的租户参数。
  • 每次写操作都携带一张签名的能力票据,票据里有动作、资源清单、字段白名单、额度和有效期。
  • 工具层只接受票据,不接受裸参数;参数超出字段白名单直接拒绝,不作为模型可纠正的错误返回。
  • 业务合法性由规则引擎判定,不由模型判定;模型只负责把自然语言翻译成意图参数。

关于幂等与重复:

  • 每个写操作有幂等台账记录,状态是三态而不是布尔,未知是独立的一态。
  • 台账先落盘再外发,唯一约束在数据库层,不靠应用层先查后插。
  • 不可幂等的操作被显式标记,并在配额上限制为至多一次,剩余的转人工。
  • 任务重试会复用任务而不是新建任务,或者至少用意图指纹拦截重复任务并行存在。

关于事务与失败:

  • 每个参与 Saga 的步骤预先声明补偿能力和补偿强度,声明不了的步骤会被显式标为需要人工。
  • 悬挂状态有独立的巡检任务,探测用的是只读接口,探测不出来就转人工而不是重发。
  • 补偿失败按步骤粒度单独标记,不触发整个流程的重试。
  • 整个 Saga 有总预算,每步调用前检查剩余预算,不够就直接进入补偿。

关于人工介入:

  • 不可逆动作的确认点在副作用之前,确认凭证里是参数原文而不是摘要。
  • 确认凭证绑定对象版本号或内容哈希,执行前重新校验,不一致则作废重来。
  • 确认凭证有时效,且确认和授权是两套独立记录,各自能独立回答"为什么允许"和"是否知情"。
  • 无补偿能力的步骤在失败时会产生明确的人工工单,而不是被静默跳过。

关于审计与追责:

  • 审计是只追加的事件流,事件里带前一条的哈希,链的根哈希定期锚定到外部存储。
  • 每个写操作事件带写前完整快照,敏感字段记哈希和部分明文而不是全量明文。
  • 事件里记实际发出去的参数原文,模型声称的理由只作为辅助线索不进证据字段。
  • 按最终状态能筛出所有未知的操作,索引存在,巡检任务每天真的在跑。

二十一、运行期该盯的不是可用性

接了真实系统之后,可用性指标基本失去了它的预警价值。系统可以做到接口成功率很高、延迟很低,同时持续地做出错误的决定——它只是很稳定地在错。这类系统真正需要盯的,是一组关于"写操作是否还值得信任"的判据。

这组判据里最重要的一个不是任何一个绝对值,而是确认通过率的变化趋势。一个健康的系统里,这个数字应该稳定在一个不算高的位置——如果长期接近百分之百,说明确认已经退化成了橡皮章,人不再看它了,这时候它提供的保护接近于零。真正的信号是它在短期内突然升高:从百分之六十跳到百分之九十五,几乎必然意味着某类操作的语义发生了变化,或者系统在试图绕过确认。把它做成一个需要告警的指标,比事后从事故里复盘发现要有效得多。

第二个是人工介入率,但要分层看。总体的人工介入率上升可能只是业务量上升导致的,不一定有问题;有意义的是按介入原因分类的构成变化。如果"悬挂待裁决"这一类的占比在上升,说明外部接口的稳定性或者你的超时设置出了问题;如果"补偿失败"的占比在上升,说明你选的补偿方案和对方的业务规则有冲突;如果"语义不匹配需要澄清"的占比在上升,那是最值得关注的信号,因为它意味着模型对用户意图的理解在退化,而这通常先于事故出现。

第三个是重复拦截次数。幂等台账拦下多少次重复执行,是一个很少有人看但非常有价值的指标。它高说明系统的重试逻辑或者界面上的重试设计在频繁触发重复;如果这个数字接近零,反而要怀疑是台账根本没生效,而不是系统特别干净。健康的系统在接入初期这个值会比较高,随着前端交互和模型行为被调整而逐步下降,但不会降到零。

第四个是确认到执行的转化时长分布。这个指标的用处在于发现一种隐蔽的退化:确认凭证开始大量过期。凭证过期的比例如果上升,说明用户的决策速度和系统的状态变化速度不匹配,解决办法是缩短确认的展示内容或者调整凭证有效期,而不是简单地延长有效期——延长有效期是在用一个技术问题掩盖一个流程问题。

二十二、权限档位应该是可以调的

很多系统在上线时把权限档位定死,然后凭感觉调整。这里更可行的做法是把权限档位本身作为一个可动态调整的量,并且调整规则明确到可以自动执行。

基本逻辑是正反馈收紧:当某类操作的自动执行比例在持续下降、或者人工介入率持续上升、或者确认通过率持续下降到某个阈值以下时,自动降级为需要逐条确认。反向的放松要更保守:只有在某类操作连续一段时间内零人工介入、零补偿失败、零悬挂的时候,才考虑把它从"需要确认"移到"可自动执行"。放松的门槛应该显著高于收紧的门槛,因为这两个决策的错误代价完全不对称——收紧的代价是多花几秒人工,放松的代价是一次可能无法撤回的事故。

这个机制的隐含要求是:权限档位的变化本身也要进审计,而且要能回答"这次为什么被降级"和"是谁批准恢复的"。一个只记录当前档位但不记录变化历史的配置,事后是没法复盘的——你会看到事故发生时那个操作是自动执行的,但你不知道为什么它当时被降级过又恢复了。

结语

回到开头那个判断:Agent 接真实业务系统的难点,不在能不能调通那一次调用,而在出错之后能不能撤得回来、说得清楚、追得到人。这篇文章讲的十几件事,本质上都在服务这三个目标。

把这三者再拆细一点,会发现全文其实反复在讲同一个原则:把"不知道"显式地建模出来。幂等台账为什么要用三态而不是布尔,悬挂为什么必须独立于成功和失败,两阶段提交为什么最容易出重复提交,人工确认为什么要绑版本号,审计为什么要区分意图和事实——它们都是同一个原则在不同层面的体现。凡是可能出现"不知道"的地方,都要给它一个名字、一个状态、一个处置流程,而不是让它被某个默认值吞掉。

第二个反复出现的原则是分权。模型负责理解和表达,不负责判断合法性和决定是否执行;系统负责确定性的规则,不依赖概率性的推理;补偿动作由规则化的映射表决定,不由模型现场生成。这三条分权线画清楚之后,很多风险会在架构层面就消失,而不是等到运行时靠防御代码去堵。

最后一点值得说:这套东西的收益在出事之前是看不见的。它会让流程变慢一点、让开发变复杂一点、让调试变啰嗦一点,而它避免掉的那些事故在没有发生的时候是纯粹的账面成本。所以真正的问题不是"值不值得投入",而是"这个系统的业务重要性配不配得上一个能说清楚的系统"。当答案是配得上的时候,上面这套东西的复杂度反而是最低的路径——因为它不要求你去改第三方系统,也不要求你去说服业务方接受不可预测的行为,它只要求你自己的系统足够诚实。

诚实这个词在这里是字面意义上的:把状态如实记录下来,把不知道的部分如实标出来,把做不到的部分如实告诉用户。一个在这些地方含糊的系统,短期内看起来更流畅,但它的每一次故障都需要有人用更多的力气去搞清楚发生了什么,而搞清楚的时间越长,损失越大。

二十三、并发写同一个对象:Agent 带来的新竞态

Agent 引入的并发压力和传统集成有一个性质上的不同。传统集成里,同时修改同一个对象的通常是有限的几条固定链路,而且它们的时序是开发者能推断的。Agent 场景里,同时修改同一个对象的可能是一个用户的多个任务、几个用户各自的任务、以及一个定时任务和用户任务撞在一起,而它们之间的时序是模型决策的结果,开发者无法预先知道。

最直接的表现是丢失更新。两个任务都读到了订单的当前状态,都基于这个状态生成了修改方案,都提交了,后提交的那个覆盖了先提交的那个,而先提交的那个任务毫不知情地认为自己的修改生效了。这类问题在任何并发系统里都存在,但 Agent 让它变得更容易发生,因为模型的规划过程耗时长且不可预测,撞车的概率显著上升。

解决办法是乐观锁,而且必须配一个像样的冲突处理策略。这里容易做错的地方是:只加了版本号校验,冲突了就报错重试。这个策略在传统系统里勉强能用,在 Agent 系统里会导致一种很糟糕的体验——任务反复失败,模型不断改变参数重试,最后可能用一种看起来合理但业务上错误的方式绕过去。正确的做法是把冲突当成一个需要重新规划的信息而不是错误:把"对象已被他人修改"连同对方的修改内容一起返回给模型,让它基于新状态重新判断要不要继续,这个过程要有次数上限,超过上限就转人工。

另一个更根本的判断是:有些冲突不应该靠重试解决,而应该靠缩小写入范围解决。如果两个任务修改的是同一个对象的不同字段,那么它们本来就不该冲突,前提是你的写入不是"读出来改一改整体写回"而是"只写我改的字段"。这两者的区别在实现上很微妙但影响巨大:前者的语义是"这个对象在那一刻的状态",后者的语义是"这几个字段的新值"。Agent 场景应该强制后者。

锁的代价通常比冲突的代价高

有人会想到用悲观锁来解决,简单直接。但悲观锁在 Agent 场景下的代价很高,原因有两个:锁的持有时间不可预测(它取决于模型推理多久),而第三方系统通常不提供跨系统的锁。于是常见的折中是给单次写操作加一个很短的锁,只保护"提交"这个动作本身,而不保护"读-想-写"这个整体过程。也就是接受读-想-写不是原子的,靠版本号在提交时检测冲突。

这实际上是正确选择,因为这个折中承认了一个事实:在一个跨越模型推理和多个外部系统的流程上,你不可能持有一个有意义的锁。与其假装有原子性,不如让冲突显式发生并被处理。

二十四、时间、顺序与迟到的事实

跨系统的另一个隐蔽错误源是时间。多系统集成里事件到达顺序和处理顺序不一致是常态:Webhook 可能延迟到达,重试队列里的任务可能比新任务先完成,两个系统的时钟可能有几十毫秒的偏差。系统里如果用"最后写入者胜"来冲突解决,那么先发生的操作可能被后发生的操作覆盖,即使后者在物理上先写入。

处理这个问题需要区分两个时间:事件时间(业务上实际发生的时间,由操作本身携带,比如订单的实际发货时间)和处理时间(你的系统写入的时间)。冲突解决应该基于事件时间,处理时间只用来做监控和排序。用处理时间做冲突解决在单机系统里没问题,在跨系统里就会错。

具体到 Agent 场景,一个真实会遇到的场景是:用户说"把这单改成明天发货",Agent 改了状态;几分钟后用户又说"改成后天",Agent 又改了一次。两次对话可能由不同的任务处理,第二个任务可能先完成。如果按处理时间解决,看起来是对的;但如果第二个任务因为某种原因先完成了、第一个任务后完成,按处理时间就会把状态退回成"明天发货",而用户最后一次明确的要求是"后天"。

解决办法是让每次写操作携带一个单调递增的版本标识,并且这个标识的来源是业务事实而不是处理时序。业务事实可以是对话里的指令序号(同一会话内单调递增),也可以是用户侧的显式版本。携带这个标识的写操作在提交时校验,如果提交的版本比当前记录的版本旧,则拒绝并返回"已有更新的指令",而不是覆盖。

回调乱序的处理原则

前面提到过异步回调会乱序。处理原则是:状态推进必须是幂等且单调的。单调的含义是状态只能沿着业务定义的方向前进,不能回退;已经到达终态的操作不能被后来的回调改写。具体实现上,回调里带一个业务版本的单调序号,序号不小于当前记录的一律忽略,而序号小于当前但状态"更靠后"的需要人工判断——因为这说明有一件事发生了,但系统的记录已经越过了它。

最后这种情况是本文反复出现的那一类:检测到了不一致,但没有能力自动判断谁对。它的正确处理是显式记录并转人工,而不是按"最新到达的赢"来猜。猜错的代价是一个错误的状态被固化下来,而错误的固化比短暂的不一致难处理得多。

二十五、第三方不可用时系统该长什么样

所有这些机制最终都要面对一个问题:当你依赖的外部服务本身挂了怎么办。这里有一个常见但错误的做法是"重试到成功"。它的问题在于当对方长时间不可用时,重试会持续消耗你的配额和连接,而用户在等待期间什么都做不了,界面上只有一个转圈的图标。

更合理的设计是让不可用变成一个明确的状态而不是一段沉默的等待。具体来说,当某个关键依赖连续失败超过阈值,系统应该把相关任务转入一个待重试的队列,向用户明确说明"对方服务暂时不可用,你的操作已经排队,我们会在恢复后自动重试,也可以现在通知你结果"。这样做的价值是把一个技术状态翻译成了用户能理解的承诺,而承诺是可以被兑现也可以被追责的。

另一个必须提前准备的问题是降级路径:当写操作不可用时,读操作还能不能用?这里的答案取决于业务,但更重要的是要有预案而不是等事故时临场决定。一个常见且有效的降级设计是:把写操作降级为"生成待执行的变更集"并存下来,等对方恢复后由系统或人工批量应用。这个设计在平时没有价值,但它在关键时刻能把"业务停摆"变成"业务延迟",而延迟是可以接受的,停摆不行。

依赖不可用时的设计目标不是"尽快恢复",而是"把停摆变成延迟"。延迟有尽头且可沟通,停摆没有尽头且只能靠用户自己发现。

二十六、人工裁决不是弹窗,是一个工作台

前面几节多次提到"转人工",但转人工之后系统应该给人什么,值得单独说清楚。因为大量系统在这一点上做得极其简陋:把一条错误信息原样转给用户,让用户自己去联系客服。这实际上是把系统该做的判断工作外包给了最没有信息的人。

一个合格的人工裁决工单至少要包含五类东西,缺任何一类都会让处理人无法在合理时间内做出决定。第一类是系统在做什么、已经做到哪一步,用人类能理解的语言描述目标,而不是抛出一串步骤名。第二类是已经发生了什么:哪些步骤成功了、产生了什么可观察的外部结果、对方系统里的记录标识是什么。第三类是卡在哪里:具体的失败特征、已经尝试过的处置手段、为什么这些手段没解决问题。

第四类是两侧状态的对比,这是最关键也最常被省略的一项。如果卡在悬挂上,界面要同时显示"我方记录的状态"和"探测到的对方状态",让人一眼看出分歧在哪。第五类是可选的处置动作,并且每个动作要标注它的影响:继续等待、重试一次、按对方的实际状态修正本地记录、发起补偿、联系对方支持。每个动作都应该能被一次点击执行,而不是让处理人去猜该调哪个接口。

这里有一个设计判断值得强调:处理人做出的每一个决定都要成为系统的一次学习。具体来说,如果处理人判断"对方的探测结果不可靠,以我方记录为准",那么这个判断本身要作为一条带原因的记录写进审计,并且这类判断的分布统计应该定期被回看。如果某类悬挂的"以我方为准"比例特别高,说明探测接口对这类操作根本不适用,应该把它从自动探测路径里去掉。这个闭环是这类系统里少数几个能持续改进的地方。

二十七、从只读到可写的分阶段路径

很多团队的实际路径是:先做一个能读能答的 Agent,跑通了之后一次性给它写权限。前者风险低、反馈快,后者风险集中、暴露晚。一个更好的路径是分阶段地扩大授权,让每一步都有机会暴露问题。

第一阶段是纯只读加上提案输出。Agent 读全部数据,产出结构化提案但不执行。这个阶段能暴露模型的意图理解能力和规则覆盖度的问题,成本极低,因为所有动作都可逆。这一阶段要盯的是提案被用户接受的比例和被修改的比例,它们的变化是后续授权的信心来源。

第二阶段开放可撤销的写操作。典型的动作是创建工单、修改内部备注、更新非关键状态。失败和错误在这里的代价可控,可以放开跑量,重点验证台账、快照和审计这三样在真实流量下的表现。这一阶段最容易发现的问题是并发冲突和枚举漂移,因为真实数据比测试数据脏得多。

第三阶段开放需要逐条确认的不可逆写操作。也就是付款、对外发送这类。这一阶段的重点不再是能不能做,而是确认点的信息密度和确认疲劳的治理。同时要开始做按操作类型的权限档位动态调整,因为这一类的误操作率在不同类型之间差异很大。

第四阶段才对低风险类型开放全自动。只有那些在第三阶段跑够长时间、零人工介入、零补偿失败的类型才进入这一档。多数团队到不了第四阶段,但能在第三阶段就把业务价值的大部分拿到手,这本身就是这个路径设计的意义——它让"不做到最后一步"成为一个可以接受的终态。

最后提醒一个容易忽略的推进障碍:每一阶段都需要业务方重新确认一次他们能接受的风险,而这个过程往往比技术建设更耗时。所以从第一天起就应该把"我们会走到哪一档、每档的风险是什么"写成一份和业务方共同维护的文档,而不是等系统做好了才去解释。这份文档同时也是出事之后最有用的一份东西。

二十八、两个容易被跳过的收尾技术

幂等键的构造比幂等机制本身更难

很多实现里,幂等键是随手生成的一个随机数,比如任务开始时调一次随机数生成器。这个做法只解决了"同一次任务内的重试"去重,解决了第二节提到的跨任务重复去重,但它的代价是任务一旦重新执行,同一个用户意图就会产生一个全新的键,台账拦不住。

更有用的构造方式是从业务事实里推导一个稳定的键。构造过程分三步:先确定这次操作针对哪些对象,用它们的稳定标识排序后拼接;再确定这次操作的关键参数,比如动作类型、目标状态、涉及的数量;最后把两者组合成一个字符串,取哈希作为幂等键。这个键的特点是:同一个用户意图重复执行会产生同样的键,而不同的意图会产生不同的键。

这个构造方式的代价是必须想清楚"哪些参数属于意图、哪些属于实现细节"。属于实现细节的绝对不能进键,否则同一个意图在系统升级前后会产生不同的键,去重就失效了;属于意图的必须全部进键,否则两个本来不同的操作会被误判成重复。这类判断需要业务知识,不能靠通用规则推导出来,所以这一层的实现应该和具体的操作类型一起写,而不是做成一个通用函数。

还有一点值得注意:键的稳定性依赖于参与拼接的对象标识本身是稳定的。如果对象标识是每次查询返回的、带版本号或者带时间戳的,那这个键在第二次执行时就会变。所以从对象里取标识时,要明确指定取哪个字段,并且禁止用任何会随时间变化的字段。

预演作为最后一道防线

前面讲了两阶段确认和影子表,其中都隐含了一个能力:在不产生外部副作用的前提下,把这次执行会发生什么算出来。这个能力值得单独拿出来,因为它可以独立于事务机制使用,作为所有不可逆动作之前的一道通用防线。

预演的做法是让执行链路跑一遍,但把最终的外部写调用替换成本地的一次干跑:参数照常组装、校验照常执行、权限照常检查,只在最后一步拦截下来,把"我将要向对方发送这些参数"记录下来。预演通过的判据应该是完整的:所有校验都过、权限检查通过、规则判定合法、参数组装成功。任何一步失败都在预演阶段暴露,代价是零。

预演的局限也要说清楚:它无法保证真正执行时对方的状态和预演时一致,也无法覆盖对方自身的业务校验失败。所以它是一道能拦掉大量问题的前置检查,不是一道能替代确认的机制。合理的用法是把预演放在确认之前——用预演的结果生成确认页面上的内容,这样用户看到的"将要发生什么"是真实计算出来的,而不是模型描述的。前面说过确认页面上最有价值的信息是参数原文,预演恰好能提供一份可信的原文。

实现上有一个容易忽略的细节:预演必须和真正执行走同一条代码路径,只有最后一步不同。如果预演有一套独立的校验逻辑,那么它和真正执行的不一致会成为一类新的、极难发现的错误源——系统在预演时显示一切正常,实际执行却失败,而这类失败的排查方向往往被误导到"这个预演是不是过期了"上去。

二十九、这些东西放在哪一层,以及谁维护

前面讲的所有机制,在实现上都会面临同一个组织问题:它们既不属于业务逻辑,也不属于基础设施,放错位置就没人维护。放得太靠近业务,每个业务团队都要自己实现一遍,而且实现质量取决于这个团队里恰好有谁;放得太靠近基础设施,业务方会觉得被挡路,最后绕过它自己调接口。

比较可行的划分是三段。第一段是接入层,负责单位时区金额的规范化、枚举映射、租户注入、令牌刷新、以及"字段缺失不等于清空"这类语义防御。这一层应该是每个外部系统一个适配器,业务代码只面对统一的内部接口。判断它是否到位的一个标准是:业务代码里应该搜不到任何一个外部系统的字段名和接口路径,搜不到就说明边界清晰。

第二段是执行层,负责能力票据的签发与校验、幂等台账、Saga 编排、确认凭证、调用结果的三态分类、以及审计事件的落盘。这一层是通用的,所有写操作都走它,因此它的稳定性要求最高,也最不适合每个团队各写一份。判断它是否到位的一个标准是:能不能在代码里找到一处地方,能回答"这次调用用的是哪个幂等键、经过了什么授权、最终是什么状态",答案是分散在多个地方就说明这一层还不够收敛。

第三段是业务规则层,负责那些只有业务方知道的东西:什么订单可以合并、什么情况下必须人工、补偿应该走哪个业务动作。这一层应该由业务方拥有和修改,工程团队提供执行机制。判断它是否到位的一个标准是:业务规则能不能在不发布代码的情况下改,比如配在规则引擎里或者有配置界面。如果每次改一条业务约束都要发版,说明规则和代码耦合太紧,业务方会绕过系统自己来改数据。

这个划分里最容易被跳过的是第三层。团队的惯性是把业务规则写进工程代码,因为写起来快,而且工程师更熟悉。后果是业务方失去了对规则的掌控感,他们要么每次都来提需求,要么干脆绕过系统手动改数据库,而后者是所有一致性问题里最难查的一类来源。

三十、和业务方共同维护的那份契约

最后要单独说一下前面多次提到的那份风险清单和档位文档。它的重要性在于它是唯一一个跨团队的东西:工程团队用它指导实现,业务方用它决定开放范围,双方用它对齐预期。它的维护必须是共同参与,而不是工程团队写完发出去。

这份契约里值得写清楚的有几项。哪些操作已经开放给 Agent、开放到什么程度、每一类的不可逆程度和影响面、每一类走哪种确认路径、每一类的当前人工介入率、以及最新的事故复盘结论。最后这一项特别有用——把事故的根因回写到对应的那一行上,这行会慢慢变成一份最有价值的运维经验,团队里新来的人读一遍它就能知道每类操作为什么是现在这个样子。

这份契约还有一个副作用值得期待:它会自然地产生出权限档位的调整建议。比如清单上某一行的补偿失败率连续三个月是零、人工介入率也是零,那么它就是下一档自动化的候选;反过来某一行的确认通过率是百分之百,那它就需要重新设计确认的内容。契约因此成了一个活的文档,而不是一次性的评审材料。