检查时刻到使用时刻。
智能体系统里的每一次批准、每一次策略评估、每一次前置条件检查,都是在陈述一个「等到动作真正落地时早已挪动过」的世界——而这道间隙不再是微秒级,而是模型思考掉的那三十秒,加上人类点下按钮前花掉的那十一分钟。这就把一个经典竞态条件变成了一项设计常量:窗口长到里面的东西真的会变,而「再加一道复核」这个条件反射式的修法,是把窗口拉长而不是关掉。出路是不再去校验状态,而把决定绑到效果上——这样一个过期的决定会在边界处大声失败,而不是悄悄作用在一份没人复查第二遍的资源上。
这个窗口过去是微秒级。现在你能用挂钟量它。
「检查时刻到使用时刻」(TOCTOU)是系统编程里最古老的缺陷类别之一:程序先验证某个性质、再依据验证去动手,结果因为性质在中间变了而输掉。经典案例之所以凶险,是因为窗口极小、攻击者必须赢下一场竞速——access() 之后紧跟 open(),而符号链接在那道缝里被换掉了。
智能体循环把难度翻了过来。结构上没有任何新东西;新的是窗口长了四到六个数量级,而且在里头挪动的那样东西通常根本不是攻击者。而是一位正在编辑文档的同事、一个正在排空的队列、一次价格更新、一张被别人关掉的工单、一次策略重新部署。要输掉一场以分钟计的竞速,你并不需要对手。
# Where the time goes in one "approved" write t0 agent reads state (the CHECK) + 900 ms retrieval and tool calls + 30000 ms model reasons over the result + 2000 ms policy engine evaluates the proposed action + ~11 min human reviews the approval card + 400 ms agent re-plans with the approval in context t1 agent writes (the USE) # Window, t1 - t0, by autonomy level fully autonomous, single step 1-5 s autonomous, multi-step plan 30 s - 5 min human-in-the-loop approval 2 min - hours queued / asynchronous agent hours - days
第二块请当成风险排序来读,因为它把通常的直觉翻了个面。多数组织认为最安全的那种配置——由人类逐个批准高风险动作——拥有远远最宽的 TOCTOU 窗口;而一个第二天早上才把活接起来的排队智能体,窗口里能落下整整一个工作日的变化。自主等级与过期暴露面是朝相反方向走的,这也正是为什么「加一个人」对这一类问题里的任何一项都不是完整答案。它是一笔交易,而被交换掉的那样东西叫新鲜度。
窗口里有四样东西在变,而你的检查最多重读其中一样。
「状态变了吗?」这个问法太粗,没法据以行动。在检查与使用之间,有四样可分离的东西会漂移,它们的漂移速率各不相同,而几乎每一个实现都只重新校验第一样。
- 资源。动作所针对的那条记录、文件、工单或余额。这是所有人都会建模的那种漂移,也是唯一有标准解法的一种——版本号、ETag、序列号。
- 策略。授权了该动作的那套规则。策略包在窗口中途被重新部署,意味着这个决定是在一套已不再生效的规则下作出的;而几乎没有哪套智能体技术栈会记下「是哪个包版本产出了这次放行」。那次策略决策被当成了一个布尔值,而不是一件带日期的材料。
- 权限。智能体据以行动的凭据、范围或委派。令牌会被吊销、组成员关系会变、值班轮次会结束。这里常见的失败与安全漏洞恰好相反:智能体手里那枚令牌是在吊销之前铸出来的,于是动作成功了——因为环境权限是在凭据签发时评估的,而不是在它被使用时。
- 目标。用户的意图。在一个长窗口里,人会改主意、会在另一个渠道取消、或者底层需求已被别人解决掉了。这是那种压根没有技术解法的漂移,而且在实践中最常见——这同一个问题在循环内部的版本,见目标漂移。
这种不对称对排优先级很要紧。资源漂移修起来便宜,而且通常已被底下那个数据库修掉一半。策略与权限漂移要求你把一个版本号一路带过循环,这大概是一天的工作量,而几乎没人做过。目标漂移要求一条取消路径,而那是一项产品决定,不是工程决定。
还有第五样看着像漂移、其实不是:智能体自己先前的那些观察。一条在 t0 读到、到 t1 还躺在上下文里的工具结果,不是过期状态,而是被记住的状态,而模型分不出这两者的区别。这就是为什么一个被要求「再核对一下」的智能体,常常是从上下文里作答而不是再调一次工具——检查看起来发生了,而读取并没有发生。请把「动手前重新核验」当作一条由外壳强制执行的指令,绝不要当作一条由提示词请求的指令。
再加一层复核会让窗口更长,而不是让动作更安全。
被人指出存在过期动作时,本能反应是加一道检查。值得把「为什么这多半更糟」算清楚。一次批准本身就是一次检查,所以它继承了同样的结构:复核者在卡片渲染出来的那一刻形成了对世界的某个判断,而动作在之后才执行。因此,加一名复核者就是加一个窗口——而人工复核是上面那张表里最长的单一区段,所以它加的是可选项里最大的那一个。
更糟的是,复核者的窗口有一个机器的窗口所没有的性质:那张卡片是对 t0 时刻所捕获状态的一次渲染。一个人批准「把工单 4417 作为 4390 的重复项关闭」,批准的是一个句子,不是一笔事务。如果 4390 在四分钟前被重新打开了,卡片上什么都不会变、复核者的体验也没有任何不同,而这次批准现在是错的——错在任何程度的专注都抓不到的地方。复核者不可能比你给他看的那件材料更新。
这与面向智能体的职责分离是同一个结构性论点,只不过这次是从时间轴而非相关性轴走过来的:当第二意见与第一意见共享某项输入时,它就会失效;而这里共享的那项输入是一张快照。两位各自独立的复核者看着同一张过期卡片,会完美地达成一致,并且双双出错。
如果你保留人工批准——而对出错代价高的动作,你应该保留——那就让卡片能自我失效。渲染时带上它所依据的资源版本、在重新获得焦点时重新拉取、并给它设一个过期时间。一张开着一个小时的批准卡片,应当拒绝提交并提议刷新,就像结账页面对一个已预留座位所做的那样。这对一个批准界面是一下午的改动,而它拿掉的是系统里最长的那个窗口。
把决定绑到效果上,而不是绑到状态上。
持久的修法是:不再问「世界还和我看到的那样吗?」,而是让写入本身以「决定所假定的那个世界」为条件。于是决定会随身带着自己的前置条件,而一个过期的决定也就无法悄悄成功——它会在边界处失败,并带着一个你能记录下来的原因。
# STALE-CAPABLE: validate, then act state = read(resource) # t0 if policy.allows(action, state): # decision about t0 write(resource, action) # lands at t1, unchecked # BOUND: the write asserts what the decision assumed state = read(resource) decision = policy.evaluate(action, state) write( resource, action, if_version = state.version, # resource drift policy_bundle = decision.bundle, # policy drift on_behalf_of = decision.subject, # authority drift intent_digest = sha256(rendered_card), # goal drift idempotency_key = decision.id, # retry safety ) # The four outcomes, all of which you want to be distinguishable 200 applied 409 resource moved -> re-plan, do not retry blindly 412 policy superseded -> re-evaluate under the new bundle 403 authority revoked -> stop, surface to a human
四种机制,大致按回报高低排列。资源版本前置条件——If-Match、一条 WHERE version = n 子句、一次比较并交换——最便宜、也最普遍可得;如果你只做一件事,就做这个。把策略包标识符盖进决定、并在写入时重新断言一遍,就能把无声的策略漂移变成一个你能计数的 412。在执行时刻重新解析的代为行事断言(而不是在规划时刻铸出的持有者令牌)能堵住吊销间隙。而意图摘要——对人类所批准的那段文本精确取哈希——才是让一次批准绑定到一个效果、而不是绑定到一段「世界变了之后照样读得通」的描述上的东西。
最后那一项值得强调,因为它正是人们会跳过的那一项。一次没有绑定到「具体拟施加效果」之摘要的批准,批准的是效果的一个类别,而类别能在状态变化中存活。这就是「已批准:为订单 8812 退款」会在订单被改动之后变成一笔金额不同的退款的那套机制——没有任何东西被绕过,只是这次批准所描述的东西,比它看上去的要松。你可能已经为审计目的在写的那份决策回执,已经是你所需结构的大部分;要改的是让写入去消费它,而不只是记录它。
一道算术,告诉你这到底是不是你的问题。
不是每套系统都需要上面这些,而判据是一次乘法,不是一次主观判断。对某一类动作来说,过期执行的期望次数等于窗口长度乘以目标资源的变更速率再乘以你的量。这三样都能从你手上已有的数据里量出来。
# Expected stale actions per month E = W x R x V W window, check -> use, in hours (p50 and p95, measure both) R mutations per hour on the target resource, by OTHER writers V actions of this type per month # Worked: ticket triage with human approval W = 0.25 h (p50) / 2.5 h (p95) R = 0.04 (a given ticket is touched once per 25 h) V = 4,000 E(p50) = 0.25 x 0.04 x 4000 = 40 stale actions / month E(p95) = 2.5 x 0.04 x 4000 = 400 stale actions / month # The sensitivity nobody expects E is linear in W, and W is dominated by ONE segment: the human. Halving approval latency halves the staleness. Doubling the number of reviewers roughly doubles it.
难取的那个输入是 R,而有一个便宜的取法,不需要新上任何仪表:对你的智能体已经做过的一批动作采样,去重读目标资源的修改历史,数一数窗口之内由其他主体所做的写入。几百个样本就能给出一个按动作类别可用的速率,而这个分布几乎总是重尾的——一小撮热点资源承载了大部分暴露面。先把那些的前置条件补上,冷的那些放着不管。
对结果做两项健全性检查。如果每一类动作算出来的 E 都接近零,那你要么真的处在一个安静的领域,要么是把 R 对着自家智能体的写入、而不是对着其他写入者量的。如果算出来大得惊人,就去查那些过期动作究竟是否有害——很多是自我纠正的,而一次幂等写入作用在一份已挪动的资源上,往往是个空操作而不是缺陷。值得升级上报的数字不是「过期执行」,而是「带有不可逆效果的过期执行」,而那正是本页与爆炸半径的交集。
该建什么,以及按回报高低的顺序。
这里的工作顺序格外清楚:每一步都便宜、每一步都独立有用,而前两步就覆盖了多数系统里的大部分暴露面。
- 给每一次针对可变资源的写入加上版本前置条件。一个字段,取自你本来就做过的那次读取。回报是:过期写入开始明面失败,而不再无声作用——这就把一个未知的速率变成了一个被计数的速率。
- 把 409 与 412 当成产品指标来数。它们不是要被压掉的错误;它们是你的过期率,终于可测量了。一个触发盲目重试的 409,严格比压根没有前置条件更糟,所以请把「重新规划」设为默认处理方式,并对重试路径告警——失败模式见重试放大。
- 让批准卡片过期,并把它绑到一个摘要上。这是系统里最大的那个窗口,而这项改动压根不碰智能体,只碰复核界面。
- 在执行时刻、而不是规划时刻重新解析权限。在动作边界处取用的短生命周期凭据,而不是一路带过循环的那枚。这本来就是面向智能体的受限凭据里的建议;TOCTOU 是支持它的第二条、独立的理由。
- 给长窗口的工作一条取消路径。对排队式与异步式智能体来说,一个对用户可见、且真能抵达执行器的取消,是目标漂移唯一的缓解手段。如果用户停不下今早自己启动的那次运行,你最长的那个窗口就没有解药——见急停开关。
有一件不该做的事:不要试图靠缩短模型思考时间或削减工具调用来解决这个问题。那些区段是毫秒到几十秒级,而窗口是由人类延迟与队列深度主导的。优化一个和式里快的那部分,对这个和没有任何作用,而你会把力气花在让系统里本来好使的那部分变差。