你的 OAuth 令牌在任务执行途中过期:长时运行 Agent 的隐形故障模式
当一个生产环境中的 Agent 首次运行 40 分钟,并在 40 个步骤中的第 27 步遇到 401 错误时,故障复盘的情形几乎总是如出一辙。房间里有人会问为什么令牌没有刷新。另一个人指出刷新逻辑是存在的,但它存在于 HTTP 客户端中,而 Agent 的工具封装层(tool wrapper)从未与之对接。第三个人注意到,即使触发了刷新,Agent 的两个并行工具调用也会尝试在同一瞬间轮换同一个刷新令牌,从而导致会话崩溃。大家纷纷点头。然后,团队在接下来的一周里,为一个假设请求会在 800 毫秒内完成的架构苦哈哈地补齐凭据生命周期管理。
OAuth 的设计初衷是让访问令牌(access token)的寿命长于使用它的请求。长运行 Agent 颠覆了这一假设。现在的请求——实际上是在数分钟或数小时内编排的数十次或数百次工具调用链——比令牌活得更久。整个行业花了十年时间围绕“短请求”假设构建库、代理和刷新流,而这些几乎都无法干净地移植到 Agent 循环中。
这篇文章讨论的是这种架构失配在生产环境中的表现: 建立在其之上的失败模式、在 Agent 时间尺度上真正起作用的刷新模式,以及没人会提醒你的认证上下文漂移(auth-context drift)问题——直到审计日志显示你的 Agent 正在代表一个在 45 分钟前就已经离职的用户执行操作。
失败模式是架构性的,而非 Bug
当一个 Web 请求携带一个即将过期的令牌时,最坏的结果通常是一个 401 错误,用户点击一下即可重试。爆炸半径仅限于一个请求、一个用户、一个屏幕。Agent 在每个边界都打破了这一假设。单个长运行任务会展开为数十个工具调用,每个调用都可能访问不同的受认证 API,每个 API 都可能需要自己的刷新逻辑。任务面前没有用户可以重新点击。没有浏览器可以重定向到授权端点。通常也没有单一的进程可以在整个运行过程中持有状态。
典型的“事后补丁式” Agent 代码库包含三个层级,每一层单独看都很合理:HTTP 客户端知道在收到 401 时如何刷新令牌;工具封装层知道如何调用 HTTP 客户端;Agent 编排器知道如何对工具调用进行排序。但它们都没有端到端地管理凭据的生命周期。HTTP 客户端中的 401 处理仅涵盖单次重试,然后将错误向上抛出;工具封装层将该错误转换为工具调用失败;编排器将失败解释为任务失败,或者更糟的是,提示模型“尝试其他方法”,模型会欣然效命——有时会使用一个不需要认证且看起来成功的工具调用。
隐形变体是最糟糕的一种。Agent 在写入操作时遇到 401,重试也失败了,因为刷新从未触发,而 Agent 根据已经成功的步骤决定任务“已经足够完整”。审计日志显示任务只完成了一半。用户收到了成功通知。值班工程师在下周才从客户工单中发现问题。
主动加被动,而非二选一
在 Agent 时间尺度上站得住脚的刷新模式并非单一策略。生产系统结合了两个层级的纪律来覆盖不同的失败模式。
**主动刷新(Proactive refresh)**是基础:在访问令牌过期之前进行轮换,通常是在其寿命消耗了 70–80% 时,由颁发者的 expires_in 驱动,而不是靠客户端的猜测。仅此一点就可以防止大多数任务中途出现的 401 错误,因为 Agent 始终在使用具有充足剩余寿命的令牌操作。它还避免了针对本就即将过期的令牌触发级联重试逻辑。
**被动刷新(Reactive refresh)**是应对主动刷新遗漏情况的安全网:时钟偏移、刷新已触发但新令牌尚未同步到发起请求的节点、访问令牌在生成和使用之间被服务端撤销。当返回 401 时,封装层刷新一次并重试一次。每个方向仅限一次——再多的话,你就在对自己认证服务器发起 DDoS 攻击,同时还掩盖了真实的认证失败。
团队最常犯的错误是二选一。仅靠主动刷新会在令牌撤销和时钟漂移时崩溃。仅靠被动刷新意味着过期后的第一个请求都要耗费一次刷新的往返时间,且所有在其后排队的下游工具调用都会停滞。这种组合并不是工程品味的问题,而是失败模式本身决定的必然要求。
令牌刷新是一个并发问题
OAuth 2.0 刷新令牌在任何合理的配置下都是单次使用的。刷新令牌轮换(Refresh token rotation)——即每次使用都会使旧令牌失效并颁发一对新令牌——是 OAuth 2.1 中的默认设置,也是 MCP 授权规范中的强制要求。这对安全性很有好处,但对于在 Agent 可能针对同一个上游 API 发起十个并行工具调用的环境下的幼稚刷新代码来说,简直是灾难。
竞态条件非常简单。两个并发工具调用读取相同的存储凭据。两者都发现访问令牌已过期。两者都调用刷新端点。第一个成功并使之前的刷新令牌失效;第二个收到 refresh_token_reused 错误,大多数颁发者将其视为泄露信号,并响应以撤销整个会话。现在,Agent 循环中的所有其他工具调用都将失败,并且没有优雅的方法来重新认证一个非交互式进程。
解决方法是协同,具体级别取决于你的部署方式。对于单进程 Agent 运行时,围绕“读取-刷新-写入”循环设置内存互斥锁(mutex)就足够了:一个调用轮换令牌,其他调用等待然后读取新的令牌。对于多进程或多节点运行时,你需要分布式锁——使用带短 TTL 的 Redis SETNX 是标准做法——并在锁内增加一个“刷新前重新读取”步骤,这样锁的第二个持有者会发现有人已经轮换过了,从而跳过自己的刷新。如果没有重新读取,锁只是将竞态条件串行化了。
这种问题困扰你时的值班特征:refresh_token_reused 错误激增,且与 Agent 工作流中的扇出(fan-out)步骤相关联,随后是一大堆发给从未主动退出的用户的“重新验证你的账户”通知。
身份验证上下文漂移是最难的部分
刷新机制是可解决的工程问题。身份模型才是大多数团队会出错的地方,因为它需要决定智能体(Agent)是在谁的授权下行动,以及该授权何时结束。
想象一个典型的长时运行智能体:用户在上午 9:00 触发它;智能体在 9:45 仍在运行;而用户在 9:30 离职了。刷新令牌(Refresh token)仍然有效。智能体继续运行。每一次工具调用都继续成功。每一项操作都被归于一个已不再公司工作的用户。审计日志在技术上是准确的,但在取证上却毫无用处。
一旦你开始留意,这种漂移的变体无处不在。用户因为怀疑被钓鱼而更改了密码 —— 活动会话仍在继续。用户在设置页面撤销了对智能体的授权 —— 取决于提供商,访问令牌(Access token)会一直有效直到过期。用户注销登录 —— 结果也是一样。这些操作在设计时都没有考虑到“我的工作流仍在执行”这一情况。
真正处理这种状况的架构模式,是将每次长时运行的智能体执行视为具有自身凭证生命周期的工作流,而不是用户会话的延伸。这看起来像是:智能体在任务开始时获得一个委派令牌(Delegation token),其范围(Scope)是声明的任务所需的最小权限,其寿命是任务预期持续时间加上一小段缓冲,并且其刷新取决于“发起用户是否仍是授权主体”,而不仅仅是“刷新令牌是否仍然有效”。当用户状态检查失败时 —— 如离职、凭证更替、授权撤销 —— 智能体的下一次刷新尝试会触发故障关闭(Fail closed),任务暂停,并介入人工(用户、其经理或管理员)进行确认或取消。
这显然比让 OAuth 库的默认重试逻辑运行要多做很多工作,但这就是工作的核心。在长周期内拥有等同于人类权限的智能体,需要一套将权限视为运行中可撤销的凭证机制。除此之外的任何做法,都是在等待触发合规事故。
明确设计凭证生命周期
将这种模式付诸实践,与其说是选择一个库,不如说是决定哪一层负责契约的哪一部分。在多个智能体代码库中行之有效的层级结构大致如下。
**工具封装层(Tool wrapper)**不持有任何令牌信息。它从运行时接收一个 principal 句柄并进行 HTTP 调用;它从不读取磁盘,也不触碰刷新端点。如果收到 401 错误,它会将其作为类型化错误抛出。
**智能体运行时(Agent runtime)**持有凭证。它管理刷新令牌、访问令牌、过期时间以及用户状态检查。它暴露一个 get_access_token(principal, scopes) 原语,实现主动轮换和并发刷新锁。它还暴露一个 pause_task(reason) 原语,用于因用户状态而非网络原因导致刷新失败时。
**编排层(Orchestrator)**拥有策略。它决定任务在开始时需要哪些范围(Scopes),任务令牌应存活多久,以及如何处理 pause_task —— 是升级给用户、升级给管理员、让任务硬失败,还是根据操作的可逆性采取更精细的处理。
**身份验证服务器(Auth server)**提供硬性保证:短访问令牌寿命(MCP 规范定在 15–60 分钟是有充分理由的)、轮换刷新令牌、在秒级内传播的每主体撤销,以及受众范围令牌(Audience-scoped tokens),以确保被攻破的工具无法在其预期范围之外使用令牌。
由此产生的特性是,没有哪个层级需要思考所有事情。工具不知道刷新;运行时不知道范围;编排层不知道 HTTP。故障模式仍然存在,但它们各自都有唯一的负责人,审计日志在事后也能真正回答“发生了什么”这个问题。
将长时运行的智能体视为独立的信任边界
上述所有内容的最简版本:如果你的智能体运行时间通常比访问令牌的寿命长,那么你的访问令牌寿命就是一个你从未有意识做出的产品决策。默认设置(借鉴自十年前的短请求 OAuth 集成)是访问令牌会漂移到 60 分钟,因为文档就是这么写的。对于一个在四分钟内完成大部分任务的智能体来说,这太长了,是安全成本。对于一个运行三小时的智能体来说,这太短了,是运营成本。这两种默认设置都对你没好处。
做得好的团队不再将智能体身份验证视为一种库配置,而是将其视为具有自身生命周期事件的工作流:任务开始、令牌铸造、定期重新验证、任务完成、令牌撤销。他们根据任务形态设置访问令牌寿命,将令牌范围严格限制在任务实际声明的工具中,并在需要之前就连接好暂停与升级路径。这项工作不会出现在功能路线图中,但它是一个能通过第一次真实审计的智能体平台与不能通过的平台之间的区别。
你的智能体注定会比它们的令牌活得久。现在就为此构建,这样当它第一次在生产环境中发生时,就不会是大家第一次思考它的时候。
相关来源:
- https://www.scalekit.com/blog/how-handle-token-refresh-ai-agents
- https://nango.dev/blog/concurrency-with-oauth-token-refreshes
- https://developers.apideck.com/guides/refresh-token-race-condition
- https://www.elegantsoftwaresolutions.com/blog/mcp-oauth-2-1-token-lifecycle-patterns-production-agent-systems
- https://stytch.com/blog/oauth-for-mcp-explained-with-a-real-world-example/
- https://aembit.io/blog/mcp-authentication-and-authorization-patterns/
- https://modelcontextprotocol.io/specification/draft/basic/authorization
- https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/
- https://frontegg.com/blog/oauth-2-refresh-tokens
- https://www.apistronghold.com/blog/agents-of-chaos-ai-agent-permission-failures
- https://stytch.com/blog/ai-agent-authentication-guide/
